Свидетелство за проблемите, които можете да причините с принуда на типа и разумно познаване на функциите на „ниско ниво“ на даден език

JavaScript, езикът, който управлява браузъра и голям процент от сървърния код, може да бъде объркващ. Заедно с Python, това е един от първите езици, които новите програмисти използват, когато се учат как да кодират. Това е език от високо ниво, което означава, че голяма част от разпределението на паметта, събирането на отпадъци и дефинициите на типове не са видими за програмиста, който пише езика. JavaScript се грижи за това зад кулисите. Въпреки това, поради това високо ниво, JavaScript може да вземе някои доста странни решения, когато се справя с това как се интерпретира кодът.

Вземете например този кодов фрагмент:

const x = 3

Тук дефинираме постоянна променлива с етикет x и я присвояваме на цялото число 3. JavaScript върши много работа, за да ни помогне, дори и с такъв прост израз. Първо, предоставяйки ключовата дума const, ние казваме на JavaScript, че искаме променливата да бъде неизменна, тоест, че не може да бъде преназначавана. След това JavaScript ще се изключи и ще разпредели памет и ще управлява указателя сам. JavaScript също извежда типа на променливата. Тук предоставяме цяло число, но не сме посочили директно типа. JavaScript просто знае, че е цяло число. Подобно на повечето езици на високо ниво, JavaScript също така ни дава куп функции и методи за конвертиране от един тип в друг. Тук променливата се преобразува в низ:

x.toString()

//OUTPUT
"3"

Можем да видим, че цялото число 3, съхранено в променливата x, е преобразувано в низ.

Въведете тип принуда. Ако използваме определени оператори за определени типове променливи, можем да получим някои доста странни резултати. Например:

true + false

//OUTPUT
1

Ако използваме оператора +, можем да видим, че двата булеви типа true и false дават оценка на 1. Това е така, защото true се превръща в 1, а false се превръща в 0. Добавете ги заедно и ще получите 1. Това е доста странно, но можем да се справим по-добре. Вземете този пример:

("b" + "a" + +"a" + "a").toLowerCase()

//OUTPUT
banana

Какво! Как се случи това? Е, в JavaScript, когато „добавяте“ низове заедно, вие ги свързвате, иначе известно като обединяването им. Тук свързваме b с a и след това … изчакайте, защо има още едно + пред второто a? Символът + в JavaScript може да се използва като унарен оператор за преобразуване на низове в числа. Ако поставим + пред низ, той ще се опита да го преобразува в число. Ако низът не е валидно число, като буквата a, JavaScript ще върне стойност от тип NaN, която означава „не е число“. Освен това, когато се опитате да добавите стойност NaN към друг низ, тя ще бъде принудена в низ и ще бъде конкатенирана. За да направим нещата по-ясни, ето същия пример с междинните стъпки:

("b" + "a" + +"a" + "a").toLowerCase()

//STEP 1
("b" + "a" + NaN + "a").toLowerCase()

//STEP 2
("baNaNa").toLowerCase()

//OUTPUT
"banana"

Това е само върхът на айсберга относно това каква лудост можем да постигнем с принуда на типа. За да демонстрираме колко странни могат да станат нещата, ще изградим JavaScript компилатор само със знаците [](){}!+\=>. Това означава, че целият възможен JavaScript код може да бъде представен само с тези 11 знака, като се използва принуда на типа. До края ще имаме малък скрипт, който може да вземе всеки JavaScript код и да изведе произволна изкривена бъркотия от [](){}!+\=> символа. След това можем да вземем тази бъркотия и да я изпълним в браузъра или с помощта на Node.js и тя ще работи точно като оригиналния JavaScript код. Хайде да го направим!

Първо, като всеки друг компилатор, трябва да започнем да изграждаме нашия набор от знаци и да го съхраняваме. Можем да инициализираме празен обект, за да съхраняваме всички наши знаци.

const charset = {}

След това трябва да разберем как да представим 0 и 1 с помощта на нашата ограничена азбука на компилатора. Първият трик, който ще използваме, е да използваме същия унарен оператор + с празен масив. Можем да видим как JavaScript използва унарния оператор плюс със следния кодов фрагмент:

//INPUT
+[]

//OUTPUT
0

//INPUT
+[5]

//OUTPUT
5

//INPUT
+[1, 2]

//OUTPUT
NaN

Въз основа на примера можем да представим числото 0 като +[] . За да обработим 1, можем да поставим оператора за отрицание пред празен масив, за да получим false.

//INPUT
![]

//OUTPUT
false

Оттук можем да поставим друг ! отпред, за да преобразуваме false в true и след това да поставим +, за който знаем, че преобразува променливите в числа. Това ни дава следното:

const zero = "+[]" //OUTPUT = 0
const one = "+!![]" //OUTPUT = 1

Страхотно, но как да получим останалите числа? Е, един прост подход е да повтаряме one отново и отново, докато получим желаното число. Ето един пример:

const two = "+!![] + +!![]" //OUTPUT = 2

Можем да потвърдим това, като вземем +!![] + +!![] и го въведем в конзолата на нашия браузър или терминал Node. Ще бъде оценено на 2. Вместо ръчно да повтаряме +!![] отново и отново, докато увеличим до желаното от нас число. Можем да напишем малка помощна функция, за да направим това за употреба.

function number(n) {
  if (n === 0) return zero
  return Array.from({ length: n }, () => one).join(" + ")
}

Всичко, което тази функция прави, е да вземе число n и да създаде низ, който свързва n число от +!![] заедно. Той също така управлява крайния случай, където n = 0, в който връща дефиницията, която измислихме преди. Нека го видим в действие:

//INPUT
const five = number(5)

//OUTPUT
"+!![] + +!![] + +!![] + +!![] + +!![]"

//INPUT
const zero = number(0)

//OUTPUT
"+[]"

Страхотен! Това са числата, покрити с букви. Трябва да дефинираме определен набор от букви за нашия компилатор. След като ги дефинираме с помощта на нашата азбука от осем знака, ще можем да конструираме функция, която може да генерира произволен знак за нас, без да се налага да ги дефинираме ръчно. Ако това не е ясно в момента, не се притеснявайте, ще има смисъл по-късно. Буквите, които трябва да дефинираме ръчно, са a b o e c t [space] f s r u i n S g p [backslash] d h m C.

За да получим буквата a, трябва да измислим места, които a се показват в JavaScript по подразбиране. От примера с банан знаем, че NaN се показва чрез принуда на типа и съдържа a. Ето как го получаваме:

//GET NAN AS A RESULT OF TRYING TO CONVERT AN OBJECT TO A NUMBER
+{}

//OUTPUT STEP 1
NaN

//CAST IT TO A STRING WITH +[]
+{}+[]

//OUTPUT STEP 2
"NaN"

//ACCESS THE LOWERCASE A USING BRACKET NOTATION
+{}+[][1]

//SINCE 1 IS NOT IN OUR COMPILER ALPHABET WE NEED TO USE OUR NUMBER FUNCTION
//WITH STRING INTERPOLATION
`+{}+[][${number(1)}]`

//OUTPUT STEP 3
"+{}+[][+!![]]"

//ASSIGN IT TO OUR CHARACTER SET OBJECT
charset.a = `+{}+[][${number(1)}]`

За да го изречем, вземаме празен обект и се опитваме да го преобразуваме в число с унарния оператор плюс, който връща NaN. След това го прехвърляме към низ с +[] и получаваме достъп до втората буква с нотация в скоби и индекс 1. Тъй като не можем да използваме знака 1, ние го заместваме с помощта на нашата функция number и интерполация на низ. След това присвояваме нашия метод за интерполация на низове за генериране на a към обекта charset.

Следващата поредица от знаци b o e c t [space] се получава по подобен начин. В JavaScript, ако се опитаме да принудим обект към низ, получаваме низа [object Object] като резултат. Това съдържа всички букви b o e c t [space]. Ето как го правим:

//GET [object Object] STRING AS A RESULT OF TRYING TO CONVERT AN
//OBJECT TO A STRING
({}+[])

//OUTPUT STEP 1
"[object Object]"

//ACCESS THE LETTERS WE ARE INTERESTED IN WITH THE NUMBER FUNCTION
//AND ADD THEM TO OUR CHARACTER SET OBJECT LIKE BEFORE
charset.b = `({}+[])[${number(2)}]`
charset.o = `({}+[])[${number(1)}]`
charset.e = `({}+[])[${number(4)}]`
charset.c = `({}+[])[${number(5)}]`
charset.t = `({}+[])[${number(6)}]`
charset[" "] = `({}+[])[${number(7)}]`

Можем да приложим подобна техника към знаците f s r u. Тези знаци се намират в true и false, булевите стойности по подразбиране на JavaScript. За да получим false като низ, всичко, което трябва да направим, е да отхвърлим стойността на празен масив и след това да го принудим да използваме същия метод +[], който използваме. Получаването на true е подобно, с изключение на това, че използваме две отрицания, тъй като отрицанието на false е true, и след това го прехвърляме към низ.

//DEFINE FALSE AND COERCE IT TO A STRING
(![]+[])

//OUTPUT STEP 1
"false"

//INDEX FALSE STRING AND ADD THE CHARACTERS TO THE CHARACTER SET OBJECT
//USING THE NUMBER FUNCTION
charset.f = `(![]+[])[${number(0)}]`
charset.s = `(![]+[])[${number(3)}]`

//DEFINE TRUE AND COERCE IT TO A STRING
(!![]+[])

//OUTPUT STEP 2
"true"

//INDEX TRUE STRING AND ADD THE CHARACTERS TO THE CHARACTER SET OBJECT
//USING THE NUMBER FUNCTION
charset.r = `(!![]+[])[${number(1)}]`
charset.u = `(!![]+[])[${number(2)}]`

За буквите i n можем да използваме факта, че JavaScript ще върне Infinity, ако извършим невалидна операция, като например деление на нула. Както преди, след това го принуждаваме към низ и го индексираме, за да получим знаците, които ни интересуват.

//THE NUMBER ONE IN OUR LANAGUAGE FROM BEFORE
+!![]

//THE NUMBER ZERO IN OUR LANGUAGE FROM BEFORE
+[]

//DIVIDE ONE BY ZERO
(+!![]/+[])

//COERCE TO STRING
((+!![]/+[])+[])

//OUTPUT
"Infinity"

//INDEX AND STORE IN OUR CHARACTER SET OBJECT
charset.i = `((+!![]/+[])+[])[${number(3)}]`
charset.n = `((+!![]/+[])+[])[${number(4)}]`

Досега просто разглеждахме езика за случаи, в които JavaScript генерира определени ключови думи или фрази и след това ги принуждава в низове за достъп до техните букви. Доста просто, но сега е време да увеличим малко лудостта. Ако сте добри в анаграмите, вие сте забелязали, че някои от буквите, които съхраняваме в нашия набор от знаци, за да изписваме думата constructor. Сега ще използваме това, но преди да го направим, трябва да разберем, че всички стойности в JavaScript са само обекти и можете да получите достъп до свойствата на тях, като използвате нотация в скоби. Можем да демонстрираме това чрез достъп до метода toString, който използвахме преди за прехвърляне на число към низ.

//ACCESS THE toString FUNCTION ON THE NUMBER 1
1["toString"]

//OUTPUT
ƒ toString() { [native code] }

По същия начин, вместо да имаме достъп до функцията toString, ние искаме да имаме достъп до функцията constructor, която е функцията, която дефинира число в JavaScript.

//ACCESS THE constructor FUNCTION ON THE NUMBER 1
1["constructor"]

//OUTPUT
ƒ Number() { [native code] }

Добре, така че сега всичко, което трябва да направим, е да преведем 1["constructor"] в нашата азбука на компилатора, за да получим достъп до метода на конструктора. За да направим това обаче, имаме нужда от начин да преобразуваме низа constructor в нашата азбука на компилатора. Нека дефинираме помощна функция string, която да направи това вместо нас:

function string(s) {
  return s
    .split("")
    .map((x) => {
      return charset[x]
    })
    .join("+")
}

Функцията приема низ, разделя го на отделни знаци, след което картографира всеки знак към нашата версия в нашия charset обект и ги обединява обратно чрез конкатенация с помощта на оператора +. Можем да го тестваме така:

//STRING FUNCTION
string("a")

//OUTPUT
"(+{}+[])[+!![]]"

Това ни дава удобен начин да преведем нашата азбука на компилатора. Сега, когато имаме тази функционалност, можем да превеждаме и да имаме достъп до функцията constructor.

//INSTEAD OF THE NUMBER ONE WE CAN JUST USE A EMPTY STRING FOR SIMPLICITY
[] + []

//OUTPUT STEP 1
""

//ACCESS THE CONSTRUCTOR FUNCTION THROUGH BRACKET NOTATION MAKING
//USE OF OUR string FUNCTION
`([] + [])[${string("constructor")}]`

//OUTPUT STEP 2
ƒ String() { [native code] }

//COERCE TO STRING
`([] + ([] + [])[${string("constructor")}])`

//OUTPUT STEP 3
"function String() { [native code] }"

//ACCESS CHARACTER INDEX WE ARE INTERSTED IN AND ASSIGN TO CHARACTER OBJECT
charset.S = `([]+([]+[])[${string("constructor")}])[${number(9)}]`
charset.g = `([]+([]+[])[${string("constructor")}])[${number(14)}]`

Чудесно, вече имаме достъп до S g. Можем да се възползваме от същата техника за p, но вместо да вземем конструктора за функцията String, ще използваме функцията за регулярен израз на JavaScript RegExp.

//SETUP A BASIC REGULAR EXPRESSSION AND GRAB CONSTRUCTOR FUNCTION
(/!/)[${string("constructor")}]

//OUTPUT STEP 1
ƒ RegExp() { [native code] }

//COERCE TO STRING
`([]+(/!/)[${string("constructor")}])`

//OUTPUT STEP 2
"function RegExp() { [native code] }"

//ACCESS CHARACTER INDEX WE ARE INTERSTED IN AND ASSIGN TO CHARACTER OBJECT
charset.p = `([]+(/!/)[${string("constructor")}])[${number(14)}]`

Можем също да се възползваме от функцията за регулярен израз за знака наклонена черта. Няма да се налага обаче да използваме техниката на конструкторската функция.

//SETUP A BASIC REGULAR EXPRESSSION AND COERCE TO STRING
//MAKE SURE TO PROVIDE FOUR BACKSLASHES OTHERWISE IT IS NOT A VALID
//REGULAR EXPRESSION
(/\\\\/ + [])

//OUTPUT STEP 1
"/\\\\/"

//ACCESS CHARACTER INDEX WE ARE INTERSTED IN AND ASSIGN TO CHARACTER OBJECT
//WE NEED TO STORE ESCAPE THE BACKSLASH WITH ANOTHER BACKSLASH
//THAT IS WHY THERE ARE TWO BACKSLASHES
charset.["\\"] = `(/\\\\/ + [])[${number(1)}]`

Добре, почти стигнахме, остават само d h m C. За да получим d h m, трябва да използваме функция на метода toString. Интересно свойство на метода toString е, че ако му подадете число, той ще оцени низа, използвайки основата на числото, което сте предали. Ето примера:

//REGULAR BASE 10 NUMBER SYSTEM
13["toString"](10)

//OUTPUT STEP 1
"13"

//BINARY BASE 2 NUMBER SYSTEM
13["toString"](2)

//OUTPUT STEP 2
"1101"

От кодовия фрагмент можем да видим, че предаването на различни бази за нашата бройна система, като 10 и 2, дава съответните изходни низове. Експлойтът, който искаме да използваме обаче, е, че числовите системи, по-големи от десет, започват да използват азбуката, за да представят своите числа (това е така, защото основата десет има само десет налични цифри и не съществуват други символи в масовата употреба). Ако използваме различни бази на числови системи, можем да започнем да получаваме букви, върнати от функцията toString.

//EXAMPLE WITH 13 USING BASE 14
13["toString"](14)

//OUTPUT STEP 1
"d"

//TRANSLATE TO COMPILER ALPHABET AND STORE IN CHARSET
charset.d = `(${number(13)})[${string("toString")}](${number(14)})`

//REPEAT FOR h AND m USING BASE 18 AND 23
charset.h = `(${number(17)})[${string("toString")}](${number(18)})`
charset.m = `(${number(22)})[${string("toString")}](${number(23)})`

Надявам се, че издържате. Ние сме на път да постигнем крайната странност. Най-трудният символ за получаване е C. За да го получим, използвайки нашата азбука на компилатора, ще трябва да мислим извън кутията. Можем да използваме същата техника на конструктор, както преди, но вместо да я принудим към низ от функцията String или RegExp, ние всъщност ще извикаме метода constructor за функцията Function.

//CREATE AN ARROW FUNCTION AND GET THE CONSTRUCTOR
(()=>{})[${"constructor"}]

//OUTPUT
ƒ Function() { [native code] }

Добре, но какво прави constructor във функцията Function.

По същество той приема низ, който представлява някакъв JavaScript код и го превръща във функция, която може да бъде извикана и изпълнява кода вътре. Ако сте запознати с функцията eval, тя е подобна. Можем да го използваме, за да върнем стандартната библиотечна функция escape, която ни позволява да извършим екраниране на низ към ASCII стойностите за определени символи.

//FUNCTION CONSTRUCTOR
(()=>{})[${"constructor"}]

//OUTPUT STEP 1
ƒ Function() { [native code] }

//PASS IN return escape JAVASCRIPT CODE TO THE CONSTRUCTOR
`((()=>{})[${string("constructor")}](${string("return escape")})`

//CALL THE RETURNED escape FUNCTION WITH THE BACKSLASH CHARACTER FROM
//OUR CHARACTER SET
`((()=>{})[${string("constructor")}](${string("return escape")})()(${charset["\\"]}))`

//OUTPUT STEP 2
"%5C"

//INDEX THE OUTPUT TO GET OUR CAPITAL C
`((()=>{})[${string("constructor")}](${string("return escape")})()(${
 charset["\\"]
}))[${number(2)}]`

За да обобщим, хващаме конструктора на функцията Function и му предаваме някакъв JavaScript код, който връща функцията escape. След това предаваме екранирания символ обратна наклонена черта, който получихме по-рано, във функцията за екраниране, която връща ASCII низа за този знак. Низът съдържа нашите C, които индексираме и съхраняваме в нашия набор от знаци.

Страхотно! Сега за магическата част на компилатора. Можем да редактираме нашата функция string от преди с известна актуализирана логика. До този момент, ако изпълнихме нашата string функция върху низ, който съдържа знак, който не е в нашия charset картографски обект, тогава тя връща undefined. Можем да поправим това с кода по-долу, за да включим всички знаци.

function string(s) {
  return s
    .split("")
    .map((x) => {
      if (!(x in charset)) {
        const charCode = x.charCodeAt(0)
        return `([]+[])[${string("constructor")}][${string("fromCharCode")}](${number(
              charCode
            )})`
        }
      return charset[x]
    })
    .join("+")
}

Разхождайки се през кода, ако даден символ не е в charset, можем да получим кода на знака с помощта на charCodeAt. След това го предаваме в метода fromCharCode, достъпен чрез конструктора String, който получихме преди. Ако не сте запознати с charCodeAt, всичко, което прави, е да преобразува знак в числово представяне, за което имаме алфавитни преобразувания на компилатора. Може да сте забелязали, че извикахме string рекурсивно, но това не е проблем, тъй като извикването е на "constructor”, за което вече имаме дефиниции на знаци.

Това е. Просто трябва да дефинираме функция compile с помощта на нашия конструктор Function от преди и да подадем JavaScript код.

function compile(code) {
  return `(()=>{})[${string("constructor")}](${string(code)})()`
}

JavaScript се компилира в този луд синтаксис и се изпълнява нормално. Нека го видим в действие.

Да кажем, че искаме да компилираме JavaScript кода console.log("Hello World!"). Това е напълно нормален JavaScript код, който извежда низ към конзолата. Ако извикаме compile на него, можем да видим следния изход:

//COMPILE JAVASCRIPT TO CRAZY SYNTAX
compile("console.log('Hello World!')"

//TRUNCATED OUTPUT
(()=>{})[({}+[])[+!![] + +!![] + +!![] + +!![] + +!![]]+({}+[])[+!![]]+((+!![]/+[])+[])[+!![] + +!![] + +!![] + +!![]]+(![]+[])[+!![] + +!![] + +!![]]+({}+[])[+!![] + +!![] + +!![] + +!![] + +!![] + +!![]]+(!![]+[])[+!![]]+(!![]+[])[+!![] + +!![]]+({}+[])[+!![] + +!![] + +!![] + +!![] + +!![]]+({}+[])[+!![] + +!![] + +!![] + +!![] + +!![] + +!![]]+({}+[])[+!![]]+(!![]+[])[+!![]]](({...

След това можем да копираме и поставим нашия компилиран код в нашата конзола на браузъра или терминал Node, който се изпълнява правилно!

Hello World!

Този проект е доказателство за проблемите, които можете да причините с принуда на типа и разумно познаване на функциите на „ниско ниво“ на даден език. Надявам се, че това ви е взривило малко ума и ако искате да видите кода, изхода и минимизирания компилатор, можете да го разгледате тук.