Как сайты определяют ботов? Деобфускация Akamai Bot Manager 2.0

Akamai Technologies — американская компания, занимающаяся защитой веб-ресурсов от ботов с помощью своего продукта Bot Manager. В её портфолио числятся такие гиганты ритейла, как Nike, Adidas и Asos, для которых особенно важен контроль за ботами, автоматизирующими процесс выкупа редких/лимитированных товаров с целью их перепродажи по завышенной цене. В данной статье мы взглянем на скрипт антибота Akamai и рассмотрим, какие методы обнаружения через JavaScript в нём используются. Любите автоматизацию через какой-нибудь selenium? Добро пожаловать!

В качестве подопытной страницы для исследований выберем логин на сайте Asos. Если мы попытаемся заполнить поля случайным образом, то в обычном браузере нас ждёт ошибка неверных учётных данных, но автоматизируемый браузер сразу получает Access Denied.

Логин через обычный браузер и playwright chromium

34f101b8e5f0f1109a9e0dbd5b369aec.gif

Исходя из предыдущего опыта, можем высказать предположение, что дело в каких-нибудь куках, тем не менее, глаза сами цепляются за странные запросы с большим объёмом данных:

sensor_data request payload

61d7319f1c8a0576c45fd31070328177.png

Бежим смотреть откуда была отправка и видим нечто:

Виновник наших дальнейших страданий

Виновник наших дальнейших страданий

Изучение обфускации

Примечание

У меня будет много отсылок на предыдущую статью, потому что эти темы тесно связаны. Там речь шла о клаудфлеере, поэтому если я пишу что-то про клаудфлеер или про наш предыдущий опыт, то имею в виду именно тот материал.

Давайте где-нибудь остановимся и посмотрим на происходящее:

debugger

48919e6400587ad2a6639d8b26673789.gif

Результатом вызовов функций вида 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

c122879477b506b100f9804eb12728c4.png

проверку целостности скрипта и так далее…

Давайте повнимательнее посмотрим на вызовы строк. Свойства объекта 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

7aa973b968e789ff04863e3c2d100b65.gif

Неутешительный результат исследований. Обфускация клаудфлеера на фоне такого выглядит детской. У нас там было всё понятно почти сразу: вот блок с тестом, в нём большая строка, из строки получался массив, из массива по индексу забирались значения. Здесь же наши строки забираются из функции, результат которой уже был получен с помощью вызова другой функции, а другая функция выполнялась как-то по-своему в зависимости от переданных ей значений и… «глобальных» переменных скрипта. Да-да, поглядите на их количество:

Переменные

500e9b87ec4af789664e5fd71de80b09.gif

Настоящий кошмар. Куча переменных, которым при старте выполнения скрипта присваивается какое-то значение, а ещё есть функция JY, которая меняет состояние этих переменных во время выполнения скрипта. По коду видно, что такая функция вызывается много раз в разных его частях. Вообще, я бы этот скрипт сравнил с экземпляром класса в ООП , ведь он имеет какое-то своё состояние. Грубо говоря — «оно живое»…

Если сейчас спросить людей «в теме» какой из антиботов реализует самую сложную обфускацию без виртуализации, то ответом скорее всего будет именно акамай, и не без причины. На первый, второй и третий взгляды не очень понятно что нужно делать. Но заметим одну деталь:

деталь

9ba4023d8a62435f706bb9f2e1e622d9.gif

Скрипт не заканчивает своё выполнение. Видимо, он собирает всякого рода данные, например, ваше движение мышью или изменение положения телефона в пространстве. Но, суть в том, что даже после отправки всех данных мы можем получить строки через вызовы функций. Спасибо замыканиям. Иногда получаются, конечно, всякого рода артефакты:

32e94bcc3aef6e0d8bc5c98eec1f27e3.png

Но их немного. Да и на самом деле, если нужно, можно будет просто поставить брейкпоинт в таком месте и понять что имелось в виду, но и так понятно… Я вот знаю, что в браузере 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 этого кода и описание

08016bde53618eafa475de74f37b253e.png

Мы имеет узел 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

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}`;
  }
}

Результат и дополнение

edb7bd5ec4538d56e2a68cc1a9c6363b.gif

На самом деле, из-за рекурсивной природы нашего интерпретатора, мы теперь без проблем вычисляем и такие выражения:

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

2e471da2c7307ac70ce635614dc9d7b0.PNG
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 и описание

502297b6db7547613318a0d79af37e3f.PNG

Узел называется 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 - ссылка на родительское окружение

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); // пробуем разрешить переменную
}

Поздравляю, вы добрались досюда!

Результат

8817e522f4f417955a07034059e6720d.gif

Присваивание

var a = 12213;
a = 100;
a; // 100

AST

d6eec8b92a377a71b331192dd906fd0e.PNG

Слева находится идентификатор, имя которого нам нужно забрать, а справа значение. Результатом нашей обработки узла 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

f62992a526b279edc14f2a60d7ea2615.PNG
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;
}

Результат

17a0f0aeeb1d10824744fe5f46181e1a.gif

ObjectExpression

var foo = {
  bar: 'baz'
}

foo;

AST и пояснения

f8844f3260edd12afe7e6ff44a4d4792.PNG

Главное здесь — свойство 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

2b225fae3e106d43c950e5cf25b59f22.PNG

Пояснения, думаю, не требуются. Это почти то же самое, что мы минут назад делали с объектом:

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 и пояснения

28f6cca72e2556b280bc8f76b25788d6.PNG

Снова всё просто. Выполняем узел 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

ed4d2427d542abb8980bdfe105f46382.PNG

Это чуть ли не 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

6191f72c3e3bba4679e977ba0f839015.PNG

Хочется просто написать вот так:

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?

f1c04180c8d4eefc9c42107069fa1674.PNG

Ох уж этот hoisting… Вспомнили? var-переменные и функции «поднимаются» вверх перед тем, как код будет выполнен. Если говорить про наш случай, то сначала мы должны заполнить окружение, а только потом выполнять тело блока.

Ещё пример

43c1c9a7ba7e12c6a62494e2509a19f8.PNG

Переменная 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

a7e080ac872fbdda2c2f0d139bca06c1.PNG

Имя функции — 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

e49012d9415ea606f431ee302f8f13ba.PNG

Но может быть и другая ситуация:

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

72246f7853c27be45607051ec38dc874.PNG
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)
}
1949c41c0fd55d37b970c1b0d22b0bb6.gif

Проверим работу замыканий:

var x = 0;
function foo() {
  var x = 10;
  
  function bar() {
    return x;
  }
  
  return bar();
}

foo();
fe902a296f13fb1fd43e2eeaa51dd179.gif

MemberExpression

Мы добавили объекты, но всё ещё не реализовали доступ к свойству. Закроем этот гештальт.

var object = {
  foo: 'bar'
}

console['log'](object.foo);

AST

466fb612ad5f6d9db6afc0b2efcd0ab4.png

Я не думаю, что 

© Habrahabr.ru