Как сайты определяют ботов? Деобфускация Akamai Bot Manager 2.0
Akamai Technologies — американская компания, занимающаяся защитой веб-ресурсов от ботов с помощью своего продукта Bot Manager. В её портфолио числятся такие гиганты ритейла, как Nike, Adidas и Asos, для которых особенно важен контроль за ботами, автоматизирующими процесс выкупа редких/лимитированных товаров с целью их перепродажи по завышенной цене. В данной статье мы взглянем на скрипт антибота Akamai и рассмотрим, какие методы обнаружения через JavaScript в нём используются. Любите автоматизацию через какой-нибудь selenium? Добро пожаловать!
В качестве подопытной страницы для исследований выберем логин на сайте Asos. Если мы попытаемся заполнить поля случайным образом, то в обычном браузере нас ждёт ошибка неверных учётных данных, но автоматизируемый браузер сразу получает Access Denied.
Логин через обычный браузер и playwright chromium
Исходя из предыдущего опыта, можем высказать предположение, что дело в каких-нибудь куках, тем не менее, глаза сами цепляются за странные запросы с большим объёмом данных:
sensor_data request payload
Бежим смотреть откуда была отправка и видим нечто:
Виновник наших дальнейших страданий
Изучение обфускации
Примечание
У меня будет много отсылок на предыдущую статью, потому что эти темы тесно связаны. Там речь шла о клаудфлеере, поэтому если я пишу что-то про клаудфлеер или про наш предыдущий опыт, то имею в виду именно тот материал.
Давайте где-нибудь остановимся и посмотрим на происходящее:
debugger
Результатом вызовов функций вида EE.XX(foo, bar])
или EE.yy.apply(null, [a,b,c,d])
являются строки, и именно их отсутствие нам больше всего мешает разобраться в происходящем. Тем не менее, мы ещё имеем Control Flow Flattening (который как-то распределяет код на блоки и выполняет их в определённом порядке), прокси-функции:
function plus(a, b) {
return a + b;
}
plus(plus(1, 2), plus(3, 4))
знаменитые JS-Fuck выражения:
jsfuck
проверку целостности скрипта и так далее…
Давайте повнимательнее посмотрим на вызовы строк. Свойства объекта EE
устанавливаются в каких-то таких вызовах:
EE[h8[T8]] = (function () {
var F8 = h8[T8];
return function (W8, C8, k8, l8, Y8, m8) {
var q8 = Zm(KU, [W8, Kh, vh(vh(EF)), l8, r8, m8]);
EE[F8] = function () { // установка свойства
return q8;
};
return q8;
};
})();
EE[F8]
— это функция, которая не принимает никаких аргументов, а возвращает уже готовый результат q8
. Так что все аргументы, переданные в вызов такой функции, значения никакого не имеют. Своё же значение q8
получает через вызов функции Zm
с какими-то переменными. А что за функция Zm? Давай посмотрим:
function Zm
Неутешительный результат исследований. Обфускация клаудфлеера на фоне такого выглядит детской. У нас там было всё понятно почти сразу: вот блок с тестом, в нём большая строка, из строки получался массив, из массива по индексу забирались значения. Здесь же наши строки забираются из функции, результат которой уже был получен с помощью вызова другой функции, а другая функция выполнялась как-то по-своему в зависимости от переданных ей значений и… «глобальных» переменных скрипта. Да-да, поглядите на их количество:
Переменные
Настоящий кошмар. Куча переменных, которым при старте выполнения скрипта присваивается какое-то значение, а ещё есть функция JY
, которая меняет состояние этих переменных во время выполнения скрипта. По коду видно, что такая функция вызывается много раз в разных его частях. Вообще, я бы этот скрипт сравнил с экземпляром класса в ООП , ведь он имеет какое-то своё состояние. Грубо говоря — «оно живое»…
Если сейчас спросить людей «в теме» какой из антиботов реализует самую сложную обфускацию без виртуализации, то ответом скорее всего будет именно акамай, и не без причины. На первый, второй и третий взгляды не очень понятно что нужно делать. Но заметим одну деталь:
деталь
Скрипт не заканчивает своё выполнение. Видимо, он собирает всякого рода данные, например, ваше движение мышью или изменение положения телефона в пространстве. Но, суть в том, что даже после отправки всех данных мы можем получить строки через вызовы функций. Спасибо замыканиям. Иногда получаются, конечно, всякого рода артефакты:
Но их немного. Да и на самом деле, если нужно, можно будет просто поставить брейкпоинт в таком месте и понять что имелось в виду, но и так понятно… Я вот знаю, что в браузере Brave есть функция isBrave()
, которая возвращает true. Но не суть. Нам бы нужно получить все нормальные строки.
Знаете, вот бы забрать это финальное состояние скрипта со всеми его переменными и функциями, да выполнить их потом при обходе AST в нужных местах… Мечты или такое возможно реализовать?
Есть несколько решений этой задачи со строками. Мы рассмотрим вариант очень интересный, занимательный, некрасивый и нудный. Это ленивое решение задачи «в лоб» без творческого подхода. В другой раз, возможно, я напишу про другие деликатные и более быстрые методы
Итак, скрипт хочет, чтобы его выполнили. Сделаем это!
Свой обход дерева?
Да. Мы сами обойдём дерево и выполним в нём каждый узел. Если внимательно посмотреть на скрипт, то в нём используется некоторое подмножество JS. Вы, может, заметили, что там какой-то ES3 с var-переменными, без всяких новомодных rest-spread операторов, стрелочных функций и так далее. Также, мы используем множество грязных хаков при разработке, которые облегчат нам этот нудный процесс.
Нам понадобятся:
Знание того, как работать с AST. Достаточно подробно мы изучили этот в пункт в прошлой статье про клаудфлеер, поэтому обязательно пробегитесь по ней глазами, чтобы выяснить чего вы знаете или не знаете. Для дальнейшего понимания происходящего слова traverse (), parse (), astexplorer, callExpression должны быть вам знакомы;
Babel, который мы будем использовать в качестве парсера;
jsdom и canvas, чтобы выполнять скрипт в контексте «браузерного» объекта window;
Наша задача не является сложной, так как мы пишем JavaScript на JavaScript, следовательно у нас не возникнет проблем с рантайм представлениями объектов: объект — это объект, функция — это функция, массив есть массив и всё-всё-всё уже есть в нашем языке для использования.
Список узлов, которые нам предстоит реализовать
Я пробежался по скрипту с помощью traverse()
, и узнал список всех узлов, которые используются в скрипте:
'EmptyStatement',
'ExpressionStatement',
'SequenceExpression',
'Identifier',
'BinaryExpression',
'UnaryExpression',
'StringLiteral',
'NumericLiteral',
'NullLiteral'
'BooleanLiteral'
'RegExpLiteral',
'IfStatement',
'BlockStatement'
'CallExpression',
'FunctionExpression',
'VariableDeclaration',
'VariableDeclarator',
'FunctionDeclaration',
'AssignmentExpression',
'ObjectExpression',
'ThisExpression',
'ReturnStatement',
'ObjectProperty',
'WhileStatement',
'DoWhileStatement',
'UpdateExpression',
'LogicalExpression',
'ForStatement',
'ContinueStatement',
'BreakStatement',
'MemberExpression',
'SwitchStatement',
'SwitchCase',
'ArrayExpression',
'ConditionalExpression',
'NewExpression',
'TryStatement',
'CatchClause',
'ThrowStatement',
'ForInStatement',
Реализация
Итак, подробно разберём выполнение всех узлов. Знакомая вам прелюдия:
const { parse } = require('@babel/parser');
const fs = require('fs');
const srcCode = fs.readFileSync('./input/src.js', { encoding: 'utf-8' });
const ast = parse(srcCode);
Мы прочитали код из файла и сформировали AST с помощью @babel/parser.
Первый код, который я хочу выполнить, очень прост:
10;
AST этого кода и описание
Мы имеет узел Program
, который имеет свойство body
, содержащее массив всех инструкций скрипта. Наша первая инструкция — ExpressionStatement
, содержащее в свойстве expression
узел NumericLiteral
, который представляет число 10 в своём свойстве value
.
Создадим класс Interpreter
, который будет иметь метод, принимающий узел и выполняющий его:
// ./libs/Interpreter.js
const t = require('@babel/types');
class Interpreter {
constructor() {
//... пока пусто
}
eval(node) {
if (t.isProgram(node)) { // Если это узел Program
let result;
node.body.forEach(node => { // То бежим по всем инструкциями массива node.body,
result = this.eval(node); // выполняя каждую из них
});
return result;
}
if (t.isExpressionStatement(node)) { // Если узел ExpressionStatement, то выполняем
return this.eval(node.expression); // выражение из свойсва expression
}
if (t.isNumericLiteral(node)) { // Если это литерал числа
return node.value; // Просто возвращаем число из свойства узла value
}
}
}
module.exports = Interpreter;
Как вы заметили, интерпретатор у нас будет рекурсивный. Всего 20 строчек кода, а каков результат!
Результат
Оно живое
Идём дальше. Хотим научиться складывать числа:
10 + 20; // 30
AST
Сложение — это бинарная операция, следовательно наш узел именуется как BinaryExpression. У него есть два ребёнка — left и right, которые являются типом NumericLiteral, а его мы уже вычислять научились:
10 + 20
// ... предыдущие узлы
if (t.isBinaryExpression(node)) {
const left = this.eval(node.left); // вычисляем левый операнд
const right = this.eval(node.right); // вычисляем правый операнд
switch (node.operator) {
case '+':
return left + right; // возвращаем результат
default:
throw `Unknown operator ${node.operator}`;
}
}
Результат и дополнение
На самом деле, из-за рекурсивной природы нашего интерпретатора, мы теперь без проблем вычисляем и такие выражения:
10 + 20 + 30 + 100 + (100 + 200); // 460
Парсер сам заботится о порядке действий. Добавим остальные операторы:
if (t.isBinaryExpression(node)) {
const left = this.eval(node.left);
const right = this.eval(node.right);
switch (node.operator) {
case '+':
return left + right;
case '-':
return left - right;
case '*':
return left * right;
case '/':
return left / right;
case '%':
return left % right;
case '**':
return left ** right;
case '==':
return left == right;
case '===':
return left === right;
case '!=':
return left != right;
case '!==':
return left !== right;
case '<':
return left < right;
case '<=':
return left <= right;
case '>':
return left > right;
case '>=':
return left >= right;
case '|':
return left | right;
case '&':
return left & right;
case '^':
return left ^ right;
case '<<':
return left << right;
case '>>':
return left >> right;
case '>>>':
return left >>> right;
case 'in':
return left in right;
case 'instanceof':
return left instanceof right;
default:
throw `Unknown operator ${node.operator}`;
}
}
Теперь интерпретатору по силам и такое:
10 + 100 * 20 - 40 / 50 + 4 * 100; // 2409.2
По аналогии можно сразу написать поддержку для унарных операций:
AST
if (t.isUnaryExpression(node)) {
const arg = this.eval(node.argument);
switch (node.operator) {
case '+':
return +arg;
case '-':
return -arg;
case '!':
return !arg;
case '~':
return ~arg;
case 'typeof':
return typeof arg;
case 'void':
return void arg;
default:
throw new Error(`Unknown unary operator ${node.operator}`);
}
}
По тому же шаблону добавим поддержку логических выражений. Но их особенностью является то, что не нужно вычислять оба операнда сразу, так как результат в некоторых случаях можно определить по одному из них:
1 || 0; // true, при вычислении единицы мы понимаем, что нам не важен следующий операнд
0 && 1; // false, если мы в конъюнкции уже получили 0, то это и есть результат
if (t.isLogicalExpression(node)) {
switch (node.operator) {
case '||':
return this.eval(node.left) || this.eval(node.right);
case '&&':
return this.eval(node.left) && this.eval(node.right);
case '??':
return this.eval(node.left) ?? this.eval(node.right);
default:
throw new Error(`Unknown logical operator ${node.operator}`);
}
}
Теперь мы умеем так:
1 && !0 + 1 || 0; // 2
Переменные
Настало время для интересностей. И это, наверное, самое сложное, что только есть в интерпретаторе. Остальное пойдёт куда проще.
На повестке дня такой код:
var foo = 10;
AST и описание
Узел называется VariableDeclaration
, содержащий свойство declarations
, в котором находятся все VariableDeclarator
'ы. Это массив существует на случай, когда мы пишем так:
var foo, bar;
Чтобы выполнить узел VariableDeclaration
, мы должны пробежаться по массиву declarations
и выполнить каждый VariableDeclarator
.
if (t.isVariableDeclaration(node)) {
let result;
node.declarations.forEach(variableDeclarator => {
result = this.eval(variableDeclarator);
});
return result;
}
VariableDeclarator
представляет собой узел с двумя свойствами id
— идентификатор (имя переменной) и init
— на случай, если переменная сразу инициализируется каким-либо значением.
Лексическое окружение
Как интерпретатору хранить и находить переменные и функции? Мы все уже знаем про области видимости, про цепочку областей видимости и про разрешение имён: ищем имя в текущей области видимости, если имя существует, то получаем значение по этому имени, а если не существует, то идём в родительскую область видимости и так до тех пор, пока не дойдём до глобальной области видимости, у которой нет родителя. Если переменная не найдена и там, то мы получаем знаменитую ошибку «ReferenceError — variable «foo» is not defined».
Механизмом реализации такой концепции будет выступать класс Environment:
// ./libs/Environment
class Environment {
constructor(record = {}, parent = null) {
this.record = record;
this.parent = parent;
}
// ...
}
Картинка примерно такая:
record — это хранилище для переменных. Представляет собой объект, ключи которого являются именами переменных, а значения — данные, которые связаны с этими именами. parent — ссылка на родительское окружение
Метод определения переменной в текущем окружении прост в реализации:
// --- Environment class ---
define(name, value = undefined) {
this.record[name] = value;
return value;
}
Мы просто в текущий объект record
добавляем пару имя-значение.
Теперь стоит озаботиться поиском переменной. Помним, что если переменной нет в текущем окружении, то стоит поискать её в родительском. Я предлагаю добавить метод resolve()
, возвращающий нужное окружение (текущее или родительское, или родительское родительского…), в котором определена переменная:
resolve(name) {
if (this.record.hasOwnProperty(name)) { // если имя есть в текущей record,
return this; // то возвращаем текущее окружение
}
if (this.parent === null) { // Если имени нет и негде его искать, то это ReferenceError
throw new ReferenceError(`Variable "${name}" is not defined`);
}
return this.parent.resolve(name); // Если есть родитель, давайте проверим его
}
Нам нужно значение переменной. Оно находится в объекте record
окружения, которое вернёт resolve()
:
lookup(name) { // Получаем значение по имени переменной
return this.resolve(name).record[name];
// ^
// получили окружение
}
Ну и ничего не стоит присвоить значение переменной:
assign(name, value) { // Присваиваем имени новое значение
this.resolve(name).record[name] = value;
return value;
}
Финальный код класса Environment
class Environment {
constructor(record = {}, parent = null) {
this.record = record;
this.parent = parent;
}
define(name, value = undefined) {
this.record[name] = value;
return value;
}
lookup(name) {
return this.resolve(name).record[name];
}
resolve(name) {
if (this.record.hasOwnProperty(name)) {
return this;
}
if (this.parent === null) {
throw new ReferenceError(`Variable "${name}" is not defined`);
}
return this.parent.resolve(name);
}
assign(name, value) {
this.resolve(name).record[name] = value;
return value;
}
}
Контекст исполнения
Код в JavaScript всегда выполняется внутри какого-нибудь контекста. Это абстрактное понятие, которое будет использоваться нами для разграничения исполняемого кода. Мы знаем, что в языке есть ключевой слово this
, которое в глобальном коде ссылается на глобальный объект window
, но с кодом внутри функции дела обстоят немного интереснее, и об этом мы ещё поговорим.
Наш контекст будет содержать два свойства — thisValue
и, собственно, Environment
:
// ./libs/ExecutionContext
class ExecutionContext {
constructor(thisValue, env) {
this.thisValue = thisValue;
this.env = env;
}
}
И… Это весь класс.
Глобальный контекст
Мы используем JSDOM, чтобы экспортировать его объект window. В процессе написания кода мы туда что-нибудь будем добавлять.
// ./browser-env/window.js
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const { window } = new JSDOM(`
(*)
`, {
url: 'http://127.0.0.1:3000',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
contentType: 'text/html',
});
На место (*) я скормил jsdom’у html-страницу my.asos.com, чтобы скрипт не споткнулся, если вдруг будет проверять какие-нибудь поля формы или добавлять свои фреймы в document.body
для проверок.
Нам нужно создать глобальный контекст исполнения, поэтому давайте создадим для начала GlobalEnvironment:
// ./libs/GlobalEnvironment
const Environment = require("./Environment")
const window = require('./../browser-env/window');
module.exports = new Environment(window);
GlobalExecutionContext:
// ./libs/GlobalExecutionContext
const ExecutionContext = require("./ExecutionContext");
const GlobalEnvironment = require("./GlobalEnvironment");
const window = require('./../browser-env/window');
module.exports = new ExecutionContext(window, GlobalEnvironment);
Да, это не мираж. thisValue
ссылается на window
, а поле record
класса GlobalEnvironment
тоже инициализировано window
. Так оно в JavaScript и работает. Именно поэтому, написав в глобальном коде
var a = 10;
вы можете обратиться к этой переменной как к свойству глобального объекта:
window.a; // 10
Мы модифицируем наш класс Interpreter
с учётом нововведений:
Interpreter
const t = require('@babel/types');
const Environment = require('./Environment');
const ExecutionContext = require('./ExecutionContext');
const GlobalExecutionContext = require('./GlobalExecutionContext');
class Interpreter {
constructor(execCtx = GlobalExecutionContext) {
this.callStack = [execCtx];
}
eval(node, ctx = this.callStack[this.callStack.length - 1]) {
if (t.isProgram(node)) {
let result;
node.body.forEach((node) => {
result = this.eval(node, ctx);
});
return result;
}
if (t.isExpressionStatement(node)) {
return this.eval(node.expression, ctx);
}
if (t.isNumericLiteral(node)) {
return node.value;
}
if (t.isBinaryExpression(node)) {
const left = this.eval(node.left, ctx);
const right = this.eval(node.right, ctx);
switch (node.operator) {
case '+':
return left + right;
// остальные операторы
default:
throw `Unknown operator ${node.operator}`;
}
}
if (t.isUnaryExpression(node)) {
const arg = this.eval(node.argument, ctx);
switch (node.operator) {
case '+':
return +arg;
case '-':
return -arg;
case '!':
return !arg;
case '~':
return ~arg;
case 'typeof':
return typeof arg;
case 'void':
return void arg;
default:
throw new Error(`Unknown unary operator ${node.operator}`);
}
}
if (t.isLogicalExpression(node)) {
switch (node.operator) {
case '||':
return this.eval(node.left, ctx) || this.eval(node.right, ctx);
case '&&':
return this.eval(node.left, ctx) && this.eval(node.right, ctx);
case '??':
return this.eval(node.left, ctx) ?? this.eval(node.right, ctx);
default:
throw new Error(`Unknown operator ${node.operator}`);
}
}
if (t.isVariableDeclaration(node)) {
let result;
node.declarations.forEach(variableDeclarator => {
result = this.eval(variableDeclarator, ctx);
});
return result;
}
throw `Unimplemented ${node.type} node`;
}
}
module.exports = Interpreter;
Что добавилось:
this.callStack — стек контекстов исполнения. Когда мы будем заходить в функцию, мы будем добавлять сюда новый контекст.
Метод
eval(node, ctx)
обзавёлся новым параметром —ctx
. Узел всегда выполняется в каком-нибудь контексте. По умолчанию — в верхушке callStack’а.Во все вызовы
this.eval(node)
мы добавляем текущий контекст:this.eval(node, ctx)
Вот долгожданная обработка узла VariableDeclarator
:
if (t.isVariableDeclarator(node)) {
const name = node.id.name;
const value = this.eval(node.init, ctx);
return ctx.env.define(name, value); // добавляем пару имя-значение в окружение
}
Теперь мы хотим научиться разрешать переменную:
foo;
Это узел обычного идентификатора:
if (t.isIdentifier(node)) {
return ctx.env.lookup(node.name); // пробуем разрешить переменную
}
Поздравляю, вы добрались досюда!
Результат
Присваивание
var a = 12213;
a = 100;
a; // 100
AST
Слева находится идентификатор, имя которого нам нужно забрать, а справа значение. Результатом нашей обработки узла Identifier является значение переменной. Но для присваивания нам нужно имя, поэтому придётся явно обработать этот случай. Слева от знака =
может также находиться не только идентификатор, но и свойство объекта: foo.bar = 100; Поэтому такой случай тоже мы потом отдельно обработаем.
Необходимо помнить, что операторов присваивания в языке несколько: =
+=
-=
&=
и так далее… Следовательно код получается таким:
if (t.isAssignmentExpression(node)) {
if (t.isIdentifier(node.left)) {
const left = node.left.name; // Явно получаем имя идентификатора
const right = this.eval(node.right, ctx); // то, что справа от знака равно
let prevValue = this.eval(node.left, ctx); // предыдущее значение переменной
switch(node.operator) {
case '=':
return ctx.env.assign(left, right);
case '+=':
return ctx.env.assign(left, prevValue + right);
case '-=':
return ctx.env.assign(left, prevValue - right);
case '*=':
return ctx.env.assign(left, prevValue * right);
case '/=':
return ctx.env.assign(left, prevValue / right);
case '^=':
return ctx.env.assign(left, prevValue ^ right);
case '&=':
return ctx.env.assign(left, prevValue & right);
case '|=':
return ctx.env.assign(left, prevValue | right);
case '%=':
return ctx.env.assign(left, prevValue % right);
default:
throw `Unimplement operator assignment ${node.operator}`
}
}
}
Теперь мы можем выполнить такой код:
var a = 121341430;
a;
a = 100;
a += 200;
a; // 300
Почти аналогично реализуются операторы ++
--
:
if (t.isUpdateExpression(node)) {
if (t.isIdentifier(node.argument)) {
const varName = node.argument.name;
const varValue = this.eval(node.argument, ctx);
const newValue = node.operator === '++' ? varValue + 1 : varValue - 1;
if (node.prefix) {
return ctx.env.assign(varName, newValue);
}
ctx.env.assign(varName, newValue);
return varValue;
}
}
Нужно только помнить про разницу между префиксным и постфиксным вариантом:
var a = 0;
++a; // 1
a = 0;
a++; // 0
Иными словами, для префиксного мы сразу присваиваем значение, и оно же возвращается, а для постфиксного мы тоже выставляем новое значение, но возвращаем предыдущее.
EmptyStatement, SequenceExpression
Между прочем, мы уже реализовали 15 узлов! Давайте добавим ещё парочку.
Что такое EmptyStatement?
;
Вот так вот…
if (t.isEmptyStatement(node)) {
return;
}
SequenceExpression — это выражения с оператором ,
:
1,2,3,3,4,5,6; // 6
Результат такого выражения есть результат последнего выражения:
AST
if (t.isSequenceExpression(node)) {
let result;
const { expressions } = node;
expressions.forEach(expr => {
result = this.eval(expr, ctx);
});
return result;
}
Это мало чем отличается от обработки узла Program.
ThisExpression
Это тоже совсем просто:
if (t.isThisExpression(node)) {
return ctx.thisValue;
}
Результат
ObjectExpression
var foo = {
bar: 'baz'
}
foo;
AST и пояснения
Главное здесь — свойство properties. Это массив ObjectProperty, который представляет собой пару ключ-значение. Нам просто нужно переложить все такие пары в новый объект
if (t.isObjectExpression(node)) {
const object = {};
node.properties.forEach(prop => { // Бежим по всем ObjectProperty
const key = prop.key.name || prop.key.value; // ключ может быть числом или идентификатором
const value = this.eval(prop.value, ctx); // вычисляем значение
object[key] = value; // добавляем в новый объект
})
return object;
}
Мы просто создаём новый объект и заполняем его.
Мой интерпретатор поругался вот так: Unimplemented StringLiteral node. Это нужно исправить:
if (t.isLiteral(node)) {
if (t.isNullLiteral(node)) {
return null;
}
return node.value;
}
NullLiteral не имеет свойство value, поэтому мы явно обрабатываем такой случай. Потом мы этот метод ещё чуть-чуть поменяем.
ArrayExpression
var array = [1, 2, 3];
array; // [1, 2, 3]
AST
Пояснения, думаю, не требуются. Это почти то же самое, что мы минут назад делали с объектом:
if (t.isArrayExpression(node)) {
const elements = node.elements.map(el => this.eval(el, ctx));
const array = [...elements];
return array;
}
ConditionalExpression
Это тернарный оператор:
var a = true ? 100 : 200;
a; // 100;
AST и пояснения
Снова всё просто. Выполняем узел test
и в зависимости от него выполняем либо узел consequen
, либо alternate
if (t.isConditionalExpression(node)) {
if (this.eval(node.test, ctx)) {
return this.eval(node.consequent, ctx)
} else {
return this.eval(node.alternate, ctx);
}
}
IfStatement
По аналогии с предыдущим узлом можем реализовать if-else:
var a = true;
var b = 0;
if (a)
b = 20;
else
b = 40;
b; // 20
AST
Это чуть ли не 1 в 1 с тернарным оператором. Отличие лишь в том, что ветка else
необязательно должна существовать.
if (t.isIfStatement(node)) {
const test = this.eval(node.test, ctx);
if (test) {
return this.eval(node.consequent, ctx)
} else if (node.alternate !== null) {
return this.eval(node.alternate, ctx)
} else {
return undefined
}
}
BlockStatement
Что такое блок? Это просто набор инструкций:
{
var a = 10;
var b = 20;
var c = a + b;
}
AST
Хочется просто написать вот так:
if (t.isBlockStatement(node)) {
let result;
node.body.forEach(stmt => {
result = this.eval(stmt, ctx);
});
return result;
}
Это будет работать для нашего просто примера, но что если пример такой:
{
a = 10;
}
var a = 0;
Наш интерпретатор закричит: ReferenceError: Variable «a» is not defined. А что покажет консоль devtools?
Ох уж этот hoisting… Вспомнили? var-переменные и функции «поднимаются» вверх перед тем, как код будет выполнен. Если говорить про наш случай, то сначала мы должны заполнить окружение, а только потом выполнять тело блока.
Ещё пример
Переменная x
в первом console.log()
имеет значение undefined
, но она уже определена.
Создадим отдельный метод для подъёма переменных:
_hoistVariables(block, ctx) {
block.body.forEach(stmt => {
if (t.isVariableDeclaration(stmt)) {
for (const variableDeclarator of stmt.declarations) {
const name = variableDeclarator.id.name;
ctx.env.define(name, undefined);
}
}
});
}
Мы явно обходим каждый variableDeclarator, чтобы присвоить значение undefined
. Это важно! Мы должны просто добавить переменные в текущее окружение, но не нужно присваивать им никакие значения.
На самом деле нужно добавить ещё подъём функций:
_hoistVariables(block, ctx) {
block.body.forEach(stmt => {
if (t.isFunctionDeclaration(stmt)) {
this.eval(stmt, ctx)
}
if (t.isVariableDeclaration(stmt)) {
for (const variableDeclarator of stmt.declarations) {
const name = variableDeclarator.id.name;
ctx.env.define(name, undefined);
}
}
});
}
Мы пока не реализовали FunctionDeclaration, но пусть будет.
Теперь добавим вызов этого метода в нужные места:
if (t.isProgram(node)) {
this._hoistVariables(node, ctx); // сюда, чтобы не обделять глобальный код
let result;
node.body.forEach((node) => {
result = this.eval(node, ctx);
});
return result;
}
// ...
if (t.isBlockStatement(node)) {
this._hoistVariables(node, ctx); // и сюда
let result;
node.body.forEach(stmt => {
result = this.eval(stmt, ctx);
});
return result;
}
Казалось бы всё хорошо, но мы много где наврали. Изолированный контекст исполнения создают только функции, поэтому такой код должен быть обработан:
a = 10;
{
{
{
var a = 0;
}
}
}
a; // 0
Мы же получим ошибку разрешения имени переменной, так как осуществляем подъём только внутри одного блока. Разумеется в JS доступны и такие приколы:
for (var i = 0; i < 10; ++i) {
// ...
}
i; // 10
Мы не будем это исправлять. Для нашей задачи не требуется обработка таких случаев. Спасибо компании Akamai за облегчение задачи.
Далее на повестке дня много грязных хаков и костылей. Надеюсь, вы уже притупили своё внимание, чтобы не ругать меня за происходящее.
Почему так?
Я очень не хочу связывать нас с реализацией ООП и делегирующего наследования на базе прототипов. Если честно, мы и объекты-то должны реализовать по-другому. Объект — это ведь тоже своего рода окружение (Environment). Ключ-значение навеивают мысли об этом.
Но что я действительно очень хочу, так это прокрутить скрипт защиты акамая и получить строки. Я не хочу реализовывать язык программирования.
FunctionDeclaration
Что такое функция? Это именованный блок кода, который можно параметризировать какими-нибудь параметрами. Грубо говоря, мы хотим сохранить набор инструкций под определённым именем, а затем в какой-то момент начать его выполнение. Как мы помним, функции в JavaScript — замыкания. Это означает, что именованные блоки кода хранят ссылку на окружение, в котором они определены. Изобразить это можно следующим образом:
// global code
// ...
function square(x) {
return x * x;
}
// ...
Вот оно замыкание! Окружение ссылается на функцию, а функция в свою очередь ссылается обратно на окружение, в котором она определена
AST FunctionDeclaration
Имя функции — node.id.name
. Именно это имя будет ссылаться на функцию.
Массив node.params
содержит параметры функции.
Тело функции, которое выполняется при вызове — node.body
if (t.isFunctionDeclaration(node)) {
const parentEnv = ctx.env; // Ссылка на текущее окружение, в котором определям функцию
const func = function(...args) {
// какой-то код
}
ctx.env.define(node.id.name, func); // определяем функцию в текущем окружении
return;
}
При входе в функцию создаётся новый ExecutionContext, который помещается на верхушку нашего callStack
. ExecutionContext, как мы помним, состоит из значения thisValue
, а также окружения — Environment
. Перед выполнением тела функции это окружение заполняется переданными аргументами:
if (t.isFunctionDeclaration(node)) {
const self = this;
const parentEnv = ctx.env; // Ссылка на текущее окружение, в котором определям функцию
const func = function(...args) {
const activationRecord = {}; // record нового окружения
for (let i = 0; i < node.params.length; ++i) { // заполняем его аргументами
activationRecord[node.params[i].name] = args[i];
}
// Внутри функции доступен массив всех переданных аргументов
// через переменную с именем 'arguments'
activationRecord['arguments'] = [...args];
// Создаём новый контекст
const execCtx = new ExecutionContext(
this,
new Environment(activationRecord, parentEnv) // parentEnv - ЗАМЫКАНИЕ!!!
);
self.callStack.push(execCtx); // Кладём контекст на верхушку
// Выполняем тело в НОВОМ контексте
let result = self._evalFunctionBlock(node.body, execCtx);
return result;
}
ctx.env.define(node.id.name, func);
return;
}
Ещё раз поясню: Когда мы объявляем функцию, то сохраняем текущее окружение в переменную parentEnv
и создаём функцию func
, в которой создаётся новый контекст с окружением, имеющим в родителе parentEnv
. То есть при вызове функции где-либо, контекст в ней всё равно будет создаваться с окружением, родитель которого parentEnv
. Замыкание помогло нам реализовать замыкание.
this
мы будем задавать явно при вызове функции в узле CallExpression череp call()
.
Вспомогательная функция
_evalFunctionBlock(block, ctx) {
this._hoistVariables(block, ctx); // поднимаем var-переменные в блоке
let result;
// выполняем каждую инструкцию
for (let s = 0; s < block.body.length; ++s) {
const stmt = block.body[s];
result = this.eval(stmt, ctx);
}
this.callStack.pop(); // снимаем текущий контект с верхушки стека вызовов
return result;
}
Нам осталось реализовать ReturnStatement. На самом деле он прост, но есть нюанс. После выполнения оператора return выполнение блока кода должно остановиться. Но блоков может быть вагон:
function square(x) {
{
{
{
{
{
return x * x;
}
}
}
}
}
}
Нам нужно выйти не из блока, а вообще из всей функции… И в таком случае очень правильно использовать исключения, а функцию выполнять в try-catch блоке, чтобы поймать результат. Исключения умеют раскручивать стек вызовов. Но мы поступим иначе… Посмотрим на нашу реализацию оператора return:
AST
Но может быть и другая ситуация:
function foo() {
return; // пусто
}
Поэтому надо и её обработать.
if (t.isReturnStatement(node)) {
let functionResult;
if (node.argument !== null) { // если есть, что возвращать, то вычислить это
functionResult = this.eval(node.argument, ctx);
}
this.callStack.pop(); // убираем с callStack текущий контекст
return functionResult;
}
То есть, ReturnStatement уберёт один ExecutionContext с this.callStack
.
В блоках кода мы будем явно проверять то, что мы находимся в текущем контексте, если же нет, то надо покинуть выполнение блока:
_evalFunctionBlock(block, ctx) {
this._hoistVariables(block, ctx);
let result;
for (let s = 0; s < block.body.length; ++s) {
const stmt = block.body[s];
result = this.eval(stmt, ctx);
// Явно проверяем, что текущий ctx это верхушка стека вызовов
if (this.callStack[this.callStack.length - 1] !== ctx) {
return result;
}
}
this.callStack.pop();
return result;
}
Подобно этому модифицируем BlockStatement:
if (t.isBlockStatement(node)) {
this._hoistVariables(node, ctx);
let result;
for (let i = 0; i < node.body.length; ++i) {
const stmt = node.body[i];
result = this.eval(stmt, ctx);
if (this.callStack[this.callStack.length - 1] !== ctx) {
return result;
}
}
return result;
}
CallExpression
Мы умеем определять функции, но не умеем их вызывать. Пора это исправить.
function square(x) {
return x * x;
}
square(10);
AST
if (t.isCallExpression(node)) {
let thisCtx;
let fn;
// Получаем через идентификатор функцию из окружения
fn = this.eval(node.callee, ctx);
// Вычисляем все узлы в массиве node.arguments,
// так как может быть, например, такой вызов: square(getNumber(10));
const args = node.arguments.map(arg => this.eval(arg, ctx));
// Мы пока не реализовали окончательно объекты,
// поэтому всегда выполняем код в текущем глобальном контексте
thisCtx = ctx.thisValue;
// Здесь произойдёт вызов функии, которая создаст окружение,
// и выполнит своё тело в новом окружении
return fn.call(thisCtx, ...args);
}
Результат
function getNumber(number) {
return number;
}
function square(x) {
return x * x;
}
var a = getNumber(1) || getNumber(0);
var b = 5;
if (a) {
square(getNumber(b));
} else {
square(100)
}
Проверим работу замыканий:
var x = 0;
function foo() {
var x = 10;
function bar() {
return x;
}
return bar();
}
foo();
MemberExpression
Мы добавили объекты, но всё ещё не реализовали доступ к свойству. Закроем этот гештальт.
var object = {
foo: 'bar'
}
console['log'](object.foo);
AST
Я не думаю, что