Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение47 мин
Количество просмотров21K

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

Я не думаю, что здесь нужны какие-то пояснения:

if (t.isMemberExpression(node)) {
  const object = this.eval(node.object, ctx);
  let prop;
  if (node.computed) { // Если свойство в квадратных скобках
    prop = this.eval(node.property, ctx);
  } else {
    prop = node.property.name;
  }
  return object[prop];
}

Мы берём из окружения объект через идентификатор, вычисляем свойство, по которому нужно к нему обратиться, и забираем из объекта свойство по имени.

Определение this в CallExpression

В простых случаях this определяется по форме выражения вызова, то есть, грубо говоря, this это то, что слева от точки:

var foo = {
  bar: function() {
    console.log(this);
  }
};

foo.bar(); // foo object

Есть неприятные случаи:

var foo = {
  bar: function() {
    console.log(this);
  }
};

(foo.bar, foo.bar)(); // window

Модифицируем обработку узла следующим образом:

if (t.isCallExpression(node)) {
  let thisCtx;
  let fn;

  // Если функция вызывается от объекта, 
  // то this - это и есть объект,
  // а функцию получаем через свойство объекта
  if (t.isMemberExpression(node.callee)) {
    thisCtx = this.eval(node.callee.object, ctx);
    const prop = node.callee.computed
      ? this.eval(node.callee.property, ctx)
      : node.callee.property.name;
    fn = thisCtx[prop];
  } else {
    fn = this.eval(node.callee, ctx);
    thisCtx = ctx.thisValue;
  }

  if (fn === undefined) {
    throw `function is not defined ${generate(node).code}`;
  }

  const args = node.arguments.map(arg => this.eval(arg, ctx));

  return fn.call(thisCtx, ...args)
}

Давайте ещё реализуем обработку узла FunctionExpression:

if (t.isFunctionExpression(node)) {
  const name = node.id ? node.id.name : undefined;
  const self = this;
  const parentEnv = ctx.env;
  const func = function(...args) {
    const activationRecord = {};
    if (name) {
      activationRecord[name] = func;
    }
    for (let i = 0; i < node.params.length; ++i) {
      activationRecord[node.params[i].name] = args[i];
    }
    activationRecord['arguments'] = [...args];
    const execCtx = new ExecutionContext(
      this,
      new Environment(activationRecord, parentEnv)
    );

    self.callStack.push(execCtx);

    let result = self._evalFunctionBlock(node.body, execCtx);
    return result;
  }

  const funcString = this.scriptCode.substring(node.loc.start.column, node.loc.end.column);
  userFunctionToString.set(func, funcString);

  return func;
}

На самом деле это практически копипаста FunctionDeclaration за тем лишь исключением, что в окружении мы саму эту функцию не определяем, потому что на такие функции мы ссылаемся посредством переменных, поэтому в последней строчке мы возвращаем эту функцию:

var a = function() {}

Также у этой функции может быть имя, чтобы рекурсивно вызывать саму себя, поэтому данное имя мы определяем в activationRecord.

Тест:

var goodBot = {
  name: 'goodBot',
  getThis: function() {
    return this.name;
  }
}

console.log(goodBot.getThis()); // goodBot

new()

Функции в JS имеют свойство prototype, которое говорит о том, какой будет прототип(__proto__) объекта, созданный от функции с помощью new().

function A() {
  this.x = 10;
}

var a = new A();

console.log(a.x); // 10
console.log(a instanceof A); // true

Грубо говоря, при вызове new() мы возвращаем из функции не результат, а this. Чтобы это сделать, нам совсем чуть-чуть нужно подправить FunctionExpression и FunctionDeclaration:

if (t.isFunctionDeclaration(node)) {
  const self = this;
  const parentEnv = ctx.env;
  const func = function(callContext, ...args) {
    const activationRecord = {};
    // ...
    // Если функция вызывается с помощью оператора new
    // выполняем её тело и возвращаем this
    if (new.target) {
      self._evalFunctionBlock(node.body, execCtx);
      return this;
    }

    let result = self._evalFunctionBlock(node.body, execCtx);
    return result;
  }

  // ...

  ctx.env.define(node.id.name, func);
  return;
}
AST NewExpression

if (t.isNewExpression(node)) {
  const callee = this.eval(node.callee, ctx);
  const args = node.arguments.map(arg => this.eval(arg, ctx));
  const result = new callee(...args);
  return result
}

toString()

Мы потом реализуем переопределение window.Function.prototype.toString, но мы должны знать строковое представление функции, так как скрипт может проверять свою целостность через проверку строкового представления. Благо скрипт акамая написан в одну строчку, а наш парсер любезно предоставляет позицию начала и конца любого узла, следовательно ничего не помешает вырезать подстроку. Я предлагаю сохранить строковое представление функции в Map<function, functionString>, чтобы при случае его забрать.

// ./utils/constants
const userFunctionToString = new Map();

module.exports = {
  userFunctionToString
}
// ./libs/Interpreter
// ...
const { userFunctionToString } = require('./../utils/constants');

class Interpreter {
  // будем дополнительно передавать весь код, чтобы вырезать из него строки
  constructor(code, execCtx = GlobalExecutionContext) {
    this.scriptCode = code;
    this.callStack = [execCtx];
  }
  // ...
  if (t.isFunctionDeclaration(node)) {
    const self = this;
    const parentEnv = ctx.env; // Ссылка на текущее окружение, в котором определям функцию
    const func = function(callContext, ...args) {
      // ...
    }

    // Вырезаем строку и сохраняем в контейнере
    const funcString = this.scriptCode.substring(node.loc.start.column, node.loc.end.column);
    userFunctionToString.set(func, funcString);
  
    ctx.env.define(node.id.name, func);
    return;
  }
  // ...
}

Циклы

Я не вижу смысла расписывать реализацию каждого цикла, так как они все очень похожи друг на друга, поэтому распишу про WhileStatement, а на остальных не буду заострять внимание.

var i = 0;
while (i < 10) {
  console.log(i++);
}
AST

Всё проще некуда: у узла есть свойство test, которое проверяется каждую итерацию, и тело, которое мы уже умеем выполнять.

Казалось бы, реализация проста:

if (t.isWhileStatement(node)) {
  const { test, body } = node;
  let result;
  while(this.eval(test, ctx)) {
    result = this.eval(body, ctx);
  }
  return result;
}

Но надо понимать, что while может быть внутри функции, у которой может быть return:

function foo() {
  var i = 0;
  while (i < 10) {
    if (i == 5) return;
    ++i;
  }
}

foo();

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

if (t.isWhileStatement(node)) {
  const { test, body } = node;
  let result;
  while(this.callStack[this.callStack.length - 1] === ctx && this.eval(test, ctx)) {
    result = this.eval(body, ctx);
  }
  return result;
}

Теперь стоит добавить поддержку continue и break. Мы реализуем это с помощью двух флажков:

class Interpreter {
  constructor(code, execCtx = GlobalExecutionContext) {
    this.scriptCode = code;
    this.callStack = [execCtx];
    // Добавим флаги
    this.flags = {
      continue: false,
      break: false
    }
  }
// ....

Затем, если мы наткнёмся на такие инструкции, то выставим эти флаги в true:

if (t.isContinueStatement(node)) {
  this.flags.continue = true;
  return;
}

if (t.isBreakStatement(node)) {
  this.flags.break = true;
  return;
}

В блоке мы должны добавить обработку этих флагов:

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

    if (this.flags.continue || this.flags.break) {
      break;
    }
  }
  return result;
}

То есть мы прекращаем выполнение блока, если натыкаемся на один из флагов. И осталось добавить обработку в сам цикл:

if (t.isWhileStatement(node)) {
  const { test, body } = node;
  let result;
  while(this.callStack[this.callStack.length - 1] === ctx && this.eval(test, ctx)) {
    result = this.eval(body, ctx);
    if (this.flags.continue) {
      this.flags.continue = false;
    }
    if (this.flags.break) {
      this.flags.break = false;
      break; 
    }
  }
  return result;
}

Флаг continue мы просто обнуляем, так как из блока уже вышли и делать ничего не нужно, но если мы наткнулись на флаг break, то цикл нужно ещё и покинуть. Это всё. Мы немного наврали, но этого достаточно.

ForStatement, DoWhileStatement
if (t.isForStatement(node)) {
  const { init, test, body } = node;
  let result;
  if (node.init) this.eval(init, ctx);
  while(this.callStack[this.callStack.length - 1] === ctx && (test ? this.eval(test, ctx) : 1)) {
    result = this.eval(body, ctx);
    if (this.flags.continue) {
      this.flags.continue = false;
    }
    if (this.flags.break) {
      this.flags.break = false;
      break;
    }
    if (node.update) {
      this.eval(node.update, ctx);
    }
  }
  return result;
}
if (t.isDoWhileStatement(node)) {
  const { test, body } = node;
  let result;
  do {
    result = this.eval(body, ctx);
    if (this.flags.continue) {
      this.flags.continue = false;
    }
    if (this.flags.break) {
      this.flags.break = false;
      break;
    }
  } while(this.callStack[this.callStack.length - 1] === ctx && this.eval(test, ctx));
  return result;
}

Try-Catch, Throw

try {
  throw 'error'
} catch(err) {
  constole.log(err)
}
AST

Самое простое здесь - реализация throw:

if (t.isThrowStatement(node)) {
  throw this.eval(node.argument, ctx);
}

Остальное тоже несложно, достаточно смотреть на AST:

if (t.isTryStatement(node)) {
  let result;
  try {
    result = this.eval(node.block, ctx);
  } catch(e) {
    const paramName = node.handler.param.name; // берём имя параметра
    ctx.env.define(paramName, e); // значение параметра в нашем catch-блоке
    result = this.eval(node.handler.body, ctx); // выполняем блок catch
  }
  if (node.finalizer) { // Если есть finally, то не стоит о нём забывать
    return this.eval(node.finalizer, ctx)
  }
  return result;
}

JS уже имеет конструкцию try-catch, поэтому она нас очень выручает. Интересным моментом здесь может быть разве что объявление нового параметра в окружении, так как ошибка именно в него и попадает.

Использование try-catch очень опасно в нашем случае, ведь если что-то в самом интерпретаторе пойдёт не так, а код будет выполняться в ThrowStatement, то мы этого даже не заметим из-за своей обработки catch, поэтому я советую добавить хотя бы console.debug какой-нибудь, чтобы быть в курсе всех ошибок, возникающих в этом месте кода.

Осталось 3 узла.

ForInStatement

Перечисление свойств объекта:

var obj = {
  a: 1,
  b: 2
}

for (var prop in obj) {
  console.log(obj[prop]);
}
AST

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

if (t.isForInStatement(node)) {
  // получаем объект, свойства которого нужно перечислить
  const object = this.eval(node.right, ctx);
  // получаем имя переменной, которая создаётся в этом цикле
  const varName = node.left.declarations[0].id.name; 
  
  for (var key in object) {
    ctx.env.define(varName, key); // объявляем переменную в текущеи окружении
    this.eval(node.body, ctx);    // выполняем тело
  }
  return;
}

SwitchStatement

Во всём скрипте используется такой вариант switch-case:

var a = 11;

switch (a) {
  case 1: {
    console.log('"1"')
  };
  break;
  case 2: {
    console.log('"2"')
  }
  break;
  case 10: {
    console.log('"10"')
  }
  break;
  default: {
    console.log('default')
  }
}

case-block-break, case-block-break, case-block-break... Мы обработаем только такой случай:

AST и пояснения

Итак, то, что находится switch(ЗДЕСЬ) - это node.discriminant. Мы должны его вычислить, а затем пробежаться по всем node.cases и проверить свойство test у каждого. Если этот testравен node.discriminant,то мы выполняем все инструкции данного case и узла node.consequent. У ветки default: node.test равен null, поэтому тоже обработаем этот случай.

if (t.isSwitchStatement(node)) {
  const test = this.eval(node.discriminant, ctx); // Вычислям switch (ЭТОТ ПАРАМЕТР)
  let result;
  for (let i = 0; i < node.cases.length; ++i) {
    const caseClause = node.cases[i];
    if (
      caseClause.test !== null && // Если это обычный case, а не default ветка
      this.eval(caseClause.test, ctx) === test
    ) {
      result = this._evalCaseClause(caseClause, ctx); // выполняем тело
      if (this.flags.break === true) { // помним про флаги
        this.flags.break = false;
      }
      break; // break из текущего цикла for. Мы закончили работать с узлом
    } else if (caseClause.test === null) { // Если ничего не подошло, то выполняем default
      result = this._evalCaseClause(caseClause, ctx);
      if (this.flags.break === true) {
        this.flags.break = false;
      }
    }
  }
  return result;
}

Вспомогательная функция, похожая на выполнение блока:

_evalCaseClause(caseClause, ctx) {
  let result;
  for (let i = 0; i < caseClause.consequent.length; ++i) {
    const stmt = caseClause.consequent[i];
    result = this.eval(stmt, ctx);
    // switch-case мог быть внутри функции,
    // поэтому мог наткнуться на return
    if (this.callStack[this.callStack.length - 1] !== ctx) {
      if (this.flags.break === true) {
        this.flags.break = false;
      }
      return result;
    }
  }
  return result;
}

Финальные штрихи

Мы забыли про обработку MemberExpression узлах присваиваний:

AssignmentExpression
if (t.isAssignmentExpression(node)) {
  if (t.isIdentifier(node.left)) {
    // уже написали
  }

  // нужно обработать отдельно, потому что узел отличается от идентификатора
  if (t.isMemberExpression(node.left)) {
    let objectName = node.left.object.name;
    let object;
    if (objectName === undefined) {
      object = this.eval(node.left.object, ctx);
    } else {
      object = ctx.env.lookup(objectName);
    }
    if (!object) {
      throw `Undefined object in assignment... ${generate(node).code}`;
    }
    let prop;
    if (node.left.computed) {
      prop = this.eval(node.left.property, ctx);
    } else {
      prop = node.left.property.name;
    }
    if (prop == undefined) {
      throw `Undefined property in assignment... ${generate(node).code}`
    }
    const propValue = this.eval(node.right, ctx);
    const prevValue = object[prop];
    switch(node.operator) {
      case '=':
        return object[prop] = propValue;
      case '+=':
        return object[prop] = prevValue + propValue;
      case '-=':
        return object[prop] = prevValue + propValue;
      case '*=':
        return object[prop] = prevValue * propValue;
      case '/=':
        return object[prop] = prevValue / propValue;
      case '^=':
        return ctx.env.assign(left, prevValue ^ propValue);
      default:
        throw `Unimplement operator assignment ${node.operator}`
    }
  }

  throw `Unimplement assignment for node type ${node.left.type}`;
}

UpdateExpression
if (t.isUpdateExpression(node)) {
  if (t.isIdentifier(node.argument)) {
    // ...
  }

  if (t.isMemberExpression(node.argument)) {
    const objectEnv = this.eval(node.argument.object, ctx);
    const prop = node.argument.computed ? this.eval(node.argument.property, ctx) : node.argument.property.name;
    const propValue = objectEnv[prop];
    const newValue = node.operator === '++' ? propValue + 1 : propValue - 1;
    if (node.prefix) {
      return objectEnv[prop] = newValue;
    }
    objectEnv[prop] = newValue;
    return propValue;
  }
}

Помимо строковых, числовых, null и прочих литералов есть ещё литералы регулярного выражения, поэтому нужно не забыть и про них:

if (t.isLiteral(node)) {
  if (t.isNullLiteral(node)) {
    return null;
  }
  if (t.isRegExpLiteral(node)) {
    return new RegExp(node.pattern, node.flags);
  }
  return node.value;
}

Это всё. Наш обход готов. Теперь мы будем контролировать выполнение скрипта, а нас будет контролировать V8.

Что за чудо нами написано?

Самому контролировать код - это суперсила. Вы можете ломать выполнение как душе угодно! Хотите выполнить ветки if и else вместе? Не вопрос. Не понимаете где именно генерируются какие-либо данные? Логируйте узлы! Да, вы теперь можете логировать выполнение любого узла, например, вызова функции. Это как автоматический отладчик! Стоит только включить фантазию.

Но у нас конкретная задача - достать "финальное" состояние скрипта и выполнить определённые вызовы функций.

Как скрипт проверяет свою целостность?

Посмотрим на функцию, которая выполняется практически в самом начале:

AST

В ней берётся toString() всего скрипта и проверяются позиции каких-то подстрок. Затем с этими позициями происходит какая-то математика... То есть, если вы форматируете код или добавите хотя бы один лишний пробел, которого в скрипте изначально не было, то вы увязните в бесконечном цикле. Да, вы можете просто вырезать эту проверку в начале, никто вам не мешает, но где гарантии, что она одна? И она действительно не одна, следовательно нам нужно на любой запрос toString() у функций выдавать правильные ответы.

Этим мы уже озаботились при обработке узлов FunctionDeclaration и FunctionExpression. При создании функции, мы ещё забираем её строковое представление из кода. Осталось его применить.

Для этого мы переопределим функцию toString:

// ./browser-env/toString.js
const { userFunctionToString } = require('./../utils/constants');

const defineToString = window => {
  const orgToString = window.Function.prototype.toString;
  window.Function.prototype.toString = function toString() {
    if (userFunctionToString.has(this)) {
      return userFunctionToString.get(this);
    }
    return orgToString.call(this);
  }

}

module.exports = defineToString;

// ./browser-env/window.js
// ...
const defineToString = require('./toString');
// ...
defineToString(window);

При вызове toString() мы изначально проверяем словарь userFunctionToString на наличии в нём функции и, если она есть, возвращаем именно её строку. Иначе возвращаем результат нативного вызова toString().

Подобным образом мы переопределим и некоторые другие объекты.

Window object

Давайте немного дополним объект window, чтобы он более походил на браузерный. Нам не требуется делать так, чтобы наши методы "не палились", главное, чтобы скрипт мог получить userAgent через navigator.userAgent, если захочет. Справедливости ради, нам даже не важно получит ли он настоящий юзерагент или строку "здесь нет юзерагента". Важно, чтобы он просто мог обратиться к полю navigator.userAgent

Чем дополнять Window?

Расписывать здесь все дополнительные объекты, какими можно дополнить окно, мне кажется излишним. Вы сможете это посмотреть в файлах. Но нужно сказать про методику поиска этих дополнительных объектов.

Как я уже упоминал, мы умеем логировать узлы. Давайте начнём логировать CallExpression:

if (t.isCallExpression(node)) {
  // ...
  // return fn.call(thisCtx, ...args) <----- раньше мы сразу возвращали результат
  // получили результат
  const result = fn.call(thisCtx, ...args);

  // обработали результат
  const resultBlackList = ['length', 'push', 'pop', 'charCodeAt', 'charAt', 'toString'];
  if (
    typeof result === 'string' &&
    !resultBlackList.includes(result) &&
    result.length > 1 && result.length < 100
  ) {
    fs.appendFileSync('./interpreter-logs/callsLog.txt', result + '\n');
  }

  // вернули результат
  return result;
}

Всяких length, push, pop и тд действительно много, поэтому их стоит исключить. Буквы и всякие toString() огромных функций нам тоже видеть ни к чему(result.length > 1 && result.length < 100).

Примечание

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

Итак, вот я вижу в нашем логе такие строчки:

document
document
document
currentScript
currentScript
currentScript

document.currentScript имеет много свойств, так как это HTMLScriptElement, поэтому переопределим это свойство таким образом:

// ./browser-env/document/currentScript.js
const defineCurrentScript = window => {
  Object.defineProperty(window.Document.prototype, 'currentScript', {
    get: () => {
      return new Proxy({}, {
        get(target, prop, r) {
          console.log('currentScript:', prop); // <-- делаем вывод в консоль
        }
      })
    }
  });
}

В консоли увидим это:

currentScript: src
currentScript: src
currentScript: src

Следовательно скрипту лишь интересно только это свойство, поэтому его и будем обрабатывать:

const defineCurrentScript = window => {
  Object.defineProperty(window.Document.prototype, 'currentScript', {
    get: () => {
      return new Proxy({}, {
        get(target, prop, r) {
          if (prop === 'src') {
            return 'https://my.asos.com/cPYhw7js-taKj/GV/951_KziztRsQ/f5OEkf5rkY5Qit/fSovAg/WXQ/MOWY5XCs'
          }
        }
      })
    }
  });
}

Я взял ссылку из дебаггера хрома на странице асоса.

Ещё пример

Лог:

window
speechSynthesis
speechSynthesis

Скрипту интересны голоса, поэтому давайте и их кое-как определим:

Голоса

В консоли девтулза видно что из себя представляет объект speechSynthesis:

Сразу можно предположить, что за получение голосов отвечает метод getVoices(), поэтому нам нужно определить его. Также можно поискать в интернете примеры использования этого метода или спросить у ChatGpt. Сам голос имеет 5 свойств, которые тоже не помешает реализовать.

Я определяю всё таким образом:

// ./browser-env/
const defineSpeechSynthesis = window => {
  class SpeechSynthesisVoice {
    constructor(def, lang, localService, name) {
      this.default_ = def;
      this.lang_ = lang;
      this.localService_ = localService;
      this.name_ = name;
      this.voiceURI_ = name;
    }

    get default() {
      return this.default_;
    }
    get lang() {
      return this.lang_;
    }
    get localService() {
      return this.localService_;
    }
    get name() {
      return this.name_;
    }
    get voiceURI() {
      return this.voiceURI_;
    }
  }

  // Добавим немного голосов, чтобы скрипту было чего собирать
  const speech1 = new SpeechSynthesisVoice(true, "ru-RU", true, "Microsoft Irina - Russian (Russia)");
  const speech2 = new SpeechSynthesisVoice(false, "en-US", true, "Microsoft Mark - English (United States)");
  const speech3 = new SpeechSynthesisVoice(false, "en-US", true, "Microsoft Zira - English (United States)");

  class speechSynthesis extends Object {
    constructor() {
      super();
      this.onvoiceschanged = null;
    }

    getVoices() {
      return [
        speech1,
        speech2,
        speech3
      ]
    }

    get onvoiceschanged() {
      return this.onvoiceschanged;
    }

    set onvoiceschanged(value) {
      this.onvoiceschanged = value;
      this.onvoiceschanged();
    }
  }
  window.speechSynthesis = new speechSynthesis();
}

module.exports = defineSpeechSynthesis;

Пример №3

Лог:

navigator
permissions
permissions
permissions
navigator.permissions

Пример использования navigator.permissions можно посмотреть где угодно. Метод query() асинхронный, поэтому вернём промис:

Код
const definePermissions = window => {
  class PermissionStatus {
    constructor(name) {
      this.name_ = name; // Мы тут наврали, но ничего страшного
      this.onchangeCallBack = null;
    }

    get name() {
      return this.name_
    }

    get onchange() {
      return this.onchangeCallBack;
    }

    set onchange(value) {
      this.onchangeCallBack = value;
      this.onchangeCallBack();
    }

    get state() {
      return 'denied'
    }
  }
  window.PermissionStatus = PermissionStatus;

  window.Permissions = function() {}
  window.Permissions.prototype.query = async function(permission) {
    const { name } = permission;
    return Promise.resolve(new window.PermissionStatus(name));
  }
}

Ну и так далее и тому подобное... Нудное занятие на самом деле, ну а что ж поделать...

Деобфускация

Как я уже говорил в начале статьи, скрипт никогда не завершает своё выполнение из-за бесконечных вызовов window.setTimeout(). Что ж, давайте ограничим это количество вызовов:

window.setTimeout = function(callBack, time) {
  console.log('timeout', timeoutCallCounter);
  ++timeoutCallCounter;
  if (timeoutCallCounter === 6) {
    // этот флажок есть будущий сигнал того,
    // что можно забирать состояние скрипта и начинать деобфускацию
    global.allTimeoutsCleaned = true;
    return;
  }
  if (time === 300000) time = 10000;
  return windowTimeout(callBack, time)
}

Я сделал вывод информации о вызовах таймаута в консоль и увидел, что первый таймаут ставится аж на 5 минут, поэтому ну его... Изменил на 10 секунд. С интервалами поступил похожим образом.

На этом этапе при запуске скрипта мы уже можем видеть такое:

Такое и интересный факт

Да, я переопределил XMLHttpRequest:

const defineXMLHttpRequest = window => {
  const XMLHttpRequestSend = window.XMLHttpRequest.prototype.send;
  window.XMLHttpRequest.prototype.send = function send(...args) {
    console.log(...args);
    return XMLHttpRequestSend.call(this, ...args);
  }

  const XMLHttpRequestOpen = window.XMLHttpRequest.prototype.open;
  window.XMLHttpRequest.prototype.open = function open(...args) {
    args[1] = 'http://127.0.0.1:3000/send'; // на всякий...
    console.log('OPEN: ', ...args);
    return XMLHttpRequestOpen.call(this, ...args);
  }
}

Эта зараза пытается отправлять пейлоады - прекрасно!

Интересный факт

Я уже говорил, что мы можем ломать выполнение скрипта? Кажется да.

Шифрование обычно не обходится без всяких дурацких операторов по типу ^=, &= и тд... Давайте их уберём ненадолго:

Какой пейлоад мы теперь увидим?

Да, он невалидный, но некоторые вещи в нём понятны. Там и юзерагент, и что-то из навигатора, и разрешение экрана, которое я устанавливал. В общем, немного покопаться, "подебажить" и станет понятно откуда начать и как сделать генератор таких данных. Но на самом деле и в свободном доступе есть информация о том, как это расшифровывается. Антибот-то популярный

PS. Заметим, что я сломал скрипт, а он все равно продолжил своё выполнение. Это и есть та опасная ситуация о которой я предупреждал во время реализации try-catch.

Замечательно. Теперь добавим в интерпретатор обработку флага allTimeoutsCleaned:

eval(node, ctx = this.callStack[this.callStack.length - 1]) {
  if (global.allTimeoutsCleaned) {
    global.interpreterState = ctx; // передадим контекст куда надо через глобальную переменную
    global.allTimeoutsCleaned = false;
    return;
  }
  // ... узлы ...

А с index.js поступим так:

// ...
const ast = parse(srcCode);

function deobfucateCode(ctx) {
  const { env } = ctx;
  const deobfuscator = new Deobfuscator(ast, env);
  deobfuscator.deobfuscate();
  const code = deobfuscator.getCode();
  fs.writeFileSync('./output/deobfuscated.js', code);
}

const interval = setInterval(() => {
  console.log('waiting interpreterState...');
  if (global.interpreterState) {
    clearInterval(interval);
    deobfucateCode(global.interpreterState)
  }
}, 3000);

console.log(new Interpreter(srcCode).eval(ast.program))

То есть, мы сами ставим интервал, который проверяет глобальную переменную, и, если она установлена, то начинаем деобфускацию. Конечно, правильнее было бы сделать это всё дело иначе, а не так халтурно, но сейчас этого достаточно.

Хватит полумерУолтер.
© Пользователи, читающие этот пост

Класс деобфускатора
// ./libs/Deobfuscator.js
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
const ExecutionContext = require('./ExecutionContext');
const window = require('./../browser-env/window');
const Interpreter = require('./Interpreter');

class Deobfuscator {
  constructor(ast, env) {
    this.ast = ast;
    this.env = env;
    this.exec = new ExecutionContext(window, env);
    this.interpreter = new Interpreter();
  }

  getCode() {
    return generate(this.ast).code;
  }

  deobfuscate() {
    this._replaceStrings();
  }

  _replaceStrings() {
    const self = this;
    traverse(this.ast, {
      CallExpression: path => {
        const { node } = path;
        if (
          t.isMemberExpression(node.callee) &&
          (
            (t.isIdentifier(node.callee.object) &&
            node.callee.object.name === 'EE') ||
            (t.isMemberExpression(node.callee.object) &&
            t.isIdentifier(node.callee.object.object) &&
            node.callee.object.object.name === 'EE')
          ) &&
          t.isIdentifier(node.callee.property)
        ) {
          try {
            let result = self.interpreter.eval(node, self.exec);
            if (typeof result === 'string') {
              path.replaceWith(t.stringLiteral(result));
            } else if (typeof result === 'number') {
              path.replaceWith(t.numericLiteral(result));
            }
          } catch(err) { console.log(err) }
        }
      }
    });
  }
  
}

module.exports = Deobfuscator;

Я даже не знаю нужно ли здесь что-либо пояснять... Мы вызываем метод _replaceStrings(), в котором traverse() обходит дерево и ищет все вызовы вида EE.P3.call(null,Gk,Sl,rd,Nh(vK)) и EE.gj(OB,Fk,DW,hv). Вы можете зайти на astexplorer и увидеть как выглядят эти узлы. Затем мы пробуем выполнить eval() и, в случае успеха, заменяем узел на результат. Поиск и замену узлов мы с вами очень подробно разбирали в предыдущем посте по клаудфлееру.

Также сайт пытается отправить sensor_data через XMLHttpRequest, следовательно хочется, чтобы он получал какие-то ответы. Вдруг они ему нужны. Для этого поднимем свой локальный сервер через express.js:

// utils/server.js
const express = require('express');

const app = express();

app.post('/send', (req, res) => {
  res.send('{"success": true}');
});

app.listen(3000, () => console.log('Server started at 3000'));
Ну что там что там

Ещё у нас есть много прокси-функций, поэтому воспользуйтесь сервисом https://deobfuscate.io/, чтобы избавиться от них и ещё больше улучшить понимание происходящего.

На самом деле мне просто не хочется снова уделять внимание обходу дерева. В прошлый раз мы много об этом говорили. Чтобы убрать промежуточные функции, достаточно знать про методы path.scope, path.scope.getBinding и уметь заменять узлы. Если вы считаете, что нужен отдельный материал для этого, то отпишитесь, и я покажу ещё раз как всем пользоваться, и, в частности, убирать прокси-функции.

Бот или не бот?

Этот вывод Akamai Bot Manager делает из отправленной ему клиентской информации, зашифрованной в "sensor_data". Если мы поймём, какая информация туда попадает, то сможем сделать предположение как определяется бот. Да, именно предположение, потому что мы знать не знаем как эти данные анализируются самим акамаем.

Итак, отправка данных осуществляется с помощью XMLHttpRequest.send(), поэтому поиском по коду натыкаемся на это:

send

В send() попадает переменная J0E, которая формируется благодаря переменной wPE, обладающей всеми данными сенсорного отпечатка. На самом деле по ходу выполнения кода она как-то перемешивает сама себя в каком-то таком стиле:

wPE = 'bar';
// ...
wPE = 'foo' + wPE; // foobar

но нам это не важно, поскольку мы делаем не генератор отпечатков, а просто смотрим какие данные собираются на клиентской стороне.

Как понять что первым делом присваивается переменной wPE? Найти ответ на этот вопрос очень просто, достаточно вывести нужную информацию в консоль из узла AssignmentExpression:

if (t.isAssignmentExpression(node)) {
  if (t.isIdentifier(node.left)) {
    const left = node.left.name;
    const right = this.eval(node.right, ctx);
    if (left === 'wPE') {
      console.log(`CODE: ${generate(node).code} | RESULT: ${right}`);
    }
    // ...
Первое присваивание

В деобфусцированном коде также просто найти саму строку:

wPE = x5E.join(Q5E)

Между числами находятся какие-то промежуточные результаты. Давайте посмотрим откуда они берутся:

Следите за руками

Сначала переход к определению нас тащит на строчку  var O5E = Ah(b0, [H5E, IW]). Функция Ah - это какая-то бурда, которая, очевидно, вызывает H5E и получает результаты. H5E представляет из себя функцию, которая проверяет некоторые свойства объектов:

props
window.screen.availWidth
window.screen.availHeight
window.screen.width
window.screen.height
window.innerHeight
window.innerWidth
'outerWidth' in window
window._phantom
window.webdriver
window.domAutomation
window.addEventListener
window.XMLHttpRequest
window.XDomainRequest
window.emit
window.DeviceOrientationEvent
window.DeviceMotionEvent
window.TouchEvent
window.spawn
window.chrome
Function.prototype.bind
window.Buffer
window.PointerEvent
window.innerWidth
window.outerWidth
window.callPhantom
window.ActiveXObject
'ActiveXObject' in window
'number' == typeof document.documentMode
window.chrome && window.chrome.webstore
navigator.onLine
window.opera
'undefined' != typeof window.InstallTrigger
window.HTMLElement && Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0
'function' == typeof window.RTCPeerConnection
'mozInnerScreenY' in window
'function' == navigator.vibrate
'function' == typeof navigator.getBattery
Array.prototype.forEach
'FileReader' in window

Система простая: если свойство присутствует, то в sensor_data попадёт 1 или значение этого свойства, а если отсутствует, то 0. Разумеется набор этих свойств отличается от браузера к браузеру. ActiveXObject будет только в IE, InstallTrigger только в Firefox, _phantom, callPhantom, webdriver, domAutomation только в автоматизируемых браузерах, window.opera только в браузере Opera, Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') будет больше 0 только в старых браузерах Safari и так далее... Следовательно, если вы выставили юзерагент от FireFox, а используете в автоматизации браузер на движке хромиума, то акамай вас заметит.

Артефакты

Мы видим некоторые артефакты в коде, но заметьте, что они не мешают нам понимать происходящее. Конкретно в данном случае используется оператор ИЛИ ||, а значит выполнение кода дальше window.innerWidth не пойдёт. Но вы можете найти это место в коде на странице asos, поставить брейкпоинт и понять, что скрывается за этими непонятными строками. Полагаю, там clientWidth.

Далее я не буду записывать как я нажимаю F12 в коде, а просто буду рассказывать что вижу.

Собираются какие-то данные формы со страницы логина:

VE === window
function W5E() {
  Gh.push(Rl);
  for (
    var B4E = '',
      d4E = -Fh,
      R4E = VE.document.getElementsByTagName('input'),
      b4E = EF;
    b4E < R4E.length;
    b4E++
  ) {
    var S4E = R4E[b4E],
      M4E = Ah(b0, [V3E, EF, S4E.getAttribute('name')]),
      K4E = Ah(b0, [V3E, EF, S4E.getAttribute('id')]),
      v4E =
        null == S4E.getAttribute('required')
          ? NF[nF]
          : EE.sEL(),
      V0E = S4E.getAttribute('type'),
      E0E = null == V0E ? -Fh : RxE(V0E),
      g0E = S4E.getAttribute('autocomplete');
    d4E =
      null == g0E
        ? -Fh
        : 'off' === (g0E = g0E.toLowerCase())
        ? EF
        : '0B' === g0E
        ? Fh
        : nF;
    var Z0E = S4E.defaultValue,
      P0E = S4E.value,
      c0E = NF[nF],
      z0E = EF;
    Z0E && NF[nF] !== Z0E.length && (z0E = Fh),
      !P0E ||
        NF[nF] === P0E.length ||
        (z0E && P0E === Z0E) ||
        (c0E = Fh),
      nF !== E0E &&
        (B4E = ''
          .concat(B4E + E0E, ',')
          .concat(d4E, ',')
          .concat(c0E, ',')
          .concat(v4E, ',')
          .concat(K4E, ',')
          .concat(M4E, ',')
          .concat(z0E, ';'));
  }
  var j0E;
  return (j0E = B4E), Gh.pop(), j0E;
}

Вообще, это неинтересно совершенно. Просто собираются инпуты, и от каждого инпута собираются значения аттрибутов name, id, required, type, autocomplete. Строчки вида M4E = Ah(b0, [V3E, EF, S4E.getAttribute('name')]), вызывают, очевидно, функцию V3E(мы уже поняли, что Ah - это какая-то функция-посредник для запутывания нас):

V3E
function V3E(pxE) {
  Gh.push(X9);
  if (null == pxE) {
    var txE;
    return (txE = -1), Gh.pop(), txE;
  }
  try {
    var GxE = Gh.slice();
    for (var JxE = 0, NxE = 0; NxE < pxE.length; NxE++) {
      var nxE = pxE.charCodeAt(NxE);
      nxE < 128 && (JxE += nxE);
    }
    var XxE;
    return (XxE = JxE), Gh.pop(), XxE;
  } catch (BxE) {
    Gh = GxE.slice();
    var dxE;
    return (dxE = -2), Gh.pop(), dxE;
  }
  Gh.pop();
}

Она принимает строку(значение атрибута) и делает странные вещи:

Странные вещи

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

Идём далее:

Ну последний раз...

Здесь происходит обработка навешенных ранее событий. Если становится непонятно что к чему, то не стесняйтесь обращаться к ChatGPT, как заметили в предыдущей статье:

ChatGPT

Соответственно, после обработки события происходит опять какая-то своя математика, чтобы всё это дело закодировать.

Самый последний...

Логика уж совсем стала понятна, я надеюсь...

Точно последний(ChatGPT)

Проверка на селениум:

collectSeleniumData

поэтому, если в вашем window или document содержатся эти свойства, то это флажок...

navigator.permissions

Через navigator.permissions.query() проверяется список этих разрешений:

и записываются их статусы. Результаты будут разниться от браузера к браузеру и от юзера к юзеру...

HTMLIframeElement srcdoc
var frame = VE.window.document.createElement('iframe');
frame.style.display = 'none';
window.document.head.appendChild(frame);
var contentWindow = frame.contentWindow;
var srcdoc;
var rndInt = randomInt().toString();
var err = 'Maximum call stack size exceeded';
try {
  srcdoc = frame.srcdoc;
} catch(e) {
  e.message.includes(err)
}
frame.srcdoc = rndInt;
frame.srcdoc !== rndInt; // must be false

Я, честно говоря, не знаю зачем это используется по сей день в коде... Когда-то в далёком прошлом в знаменитом стелс-плагине была ошибка, которая приводила к бесконечной рекурсии(https://github.com/berstend/puppeteer-extra/issues/543). Давно уже исправлено, но акамай всё ещё это проверяет. Также он устанавливает какое-то рандомное содержимое фрейма через srcdoc и смотрит, что оно установилось(frame.srcdoc !== rndInt;). То есть, если вы решили переопределить srcdoc так, чтобы сеттер просто игнорировал новое значение, то вас обнаружат. Но кто так в здравом уме будет делать...

В предыдущем тесте из фрейма берётся окно через frame.contentWindow и в нём проводятся тесты:

chrome
if (
  frameWin.chrome &&
  window.Object.keys(frameWin.chrome).length > 0
) {
  var arr = [];
  for (var prop in frameWin.chrome)
    VE.Object.prototype.hasOwnProperty.call(
      frameWin.chrome,
      prop
    ) && arr.push(prop]);
  var ZOE;
  return (ZOE = P5E(HgE(EOE.join(',')))), Gh.pop(), ZOE;
}

Проверяется, что объект chrome существует в окне фрейма

contentWindow.toString
var regex = new window.RegExp(
  /function (get )?contentWindow(\(\)) \{(\n {3})? \[native code\][\n ]\}/
);

var contentWindowString = Object.getOwnPropertyDescriptor(
  window.HTMLIFrameElement.prototype,
  'contentWindow'
).get.toString()
regex.test(contentWindowString); // must be true

Тест проверяет, что вы не переопределили свойство contentWindow, ну или переопределили его плохо...

navigator

Просто проверяется, что в окне фрейма всё то же самое, что и в главном окне.

webgl
var frame = window.document.createElement('iframe');
frame.src = 'https://'
frame.style.display = 'none';
window.document.head.appendChild(frame);
var contentWindow = frame.contentWindow;
var UNMASKED_VENDOR_WEBGL = 'NA';
var UNMASKED_RENDERER_WEBGL = 'NA';
if (contentWindow.document) {
  var context = contentWindow.document
    .createElement('canvas')
    .getContext('webgl');
  if (context) {
    var WEBGL_debug_renderer_info = context.getExtension(
      'WEBGL_debug_renderer_info'
    );
    WEBGL_debug_renderer_info &&
      ((UNMASKED_VENDOR_WEBGL = context.getParameter(
        WEBGL_debug_renderer_info.UNMASKED_VENDOR_WEBGL
      )),
      (UNMASKED_RENDERER_WEBGL = context.getParameter(
        WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL
      )));
  }
}
console.log(UNMASKED_VENDOR_WEBGL);
console.log(UNMASKED_RENDERER_WEBGL);

Можете вставить код в свою консоль, и она вам покажет вашу видеокарту.

HTMLIFrameElement.prototype.loading:

return (
  (cIE = VE.window.HTMLIFrameElement
    ? VE.Object.getOwnPropertyDescriptor(
        VE.window.HTMLIFrameElement.prototype,
        'loading'
      )
      ? '1'
      : '-2'
    : '-1'),
  Gh.pop(),
  cIE
);

Проверка существования данного свойства. Вроде как в старом headless-режиме его нет.

css
var div = document.createElement("div");
document.body.append(div);
var obj = {};
var res;
[
  "ActiveBorder",
  "ActiveCaption",
  "ActiveText",
  "AppWorkspace",
  "Background",
  "ButtonBorder",
  "ButtonFace",
  "ButtonHighlight",
  "ButtonShadow",
  "ButtonText",
  "Canvas",
  "CanvasText",
  "CaptionText",
  "Field",
  "FieldText",
  "GrayText",
  "Highlight",
  "HighlightText",
  "InactiveBorder",
  "InactiveCaption",
  "InactiveCaptionText",
  "InfoBackground",
  "InfoText",
  "LinkText",
  "Mark",
  "MarkText",
  "Menu",
  "MenuText",
  "Scrollbar",
  "ThreeDDarkShadow",
  "ThreeDFace",
  "ThreeDHighlight",
  "ThreeDLightShadow",
  "ThreeDShadow",
  "VisitedText",
  "Window",
  "WindowFrame",
  "WindowText",
].forEach(function (el) {
  div.style = "background-color: ".concat(
    el,
    " !important"
  ); // "background-color: " + el + " !important"
  var backColor = getComputedStyle(div).backgroundColor;
  obj[el] = backColor;
});
res = JSON.stringify(obj)

Результат будет отличаться от устройства к устройству и от движка к движку. Поставили юзерагент от телефона, а набор имеет компьютерный? Поставили юзерагент хрома 99, а набор имеете от хрома 105? Акамай вас поймает на этом.

navigator.connection, performance.memory
navigator.connection.rtt.toString();
if (win.window.performance && win.window.performance.memory) {
  var memoryInfo = win.window.performance.memory;
  SOE = ''
    .concat(memoryInfo.jsHeapSizeLimit, ',')
    .concat(memoryInfo.totalJSHeapSize, ',')
    .concat(memoryInfo.usedJSHeapSize);
}

В headless моде rtt вроде как будет равен нулю. Про memoryInfo мы с вами поговорили подробно в предыдущей статье

chrome object

В новых версиях браузера chrome.runtime нет, поэтому проверки уже давно бессмысленны. Тем не менее, детект на подделку этого объекта здесь поинтереснее, чем у клаудфлеера, например:

то есть, если вы при эмуляции этого объекта не обработаете случай вызова от new(), то будете замечены.

Есть ещё вот такой детект на плохое переопределение свойств навигатора:

Object.keys(Object.getOwnPropertyDescriptors(navigator))

Если вы переопределите свойства напрямую, а не через прототип, то есть как-то так:

Object.defineProperty(navigator, 'webdriver', {
    get: () => false
});

То массив с ключами пуст не будет и вас снова заметят. Но справедливости ради, никто уже сто лет так не делает, я уверен.

Набор голосов

Они просто собираются стандартными методами, без каких-либо проверок на эмуляцию

File.path

Проверяется наличие File в объекте окна и осуществляется проверка строкового представления:

crossOriginIsolated

SharedArrayBuffer будет доступен либо при crossOriginIsolated = true, либо в среде NodeJS

canvas

Он здесь просто как-то отрисовывается. Мне это не интересно, можете отрисовать сами. Не осуществляется проверка на добавление шума. Хотя, если у вас уникальный канвас, то это флажок и очень серьёзный с одной стороны, а с другой... Об этом можно отдельный пост писать

timezoneOffset
new window.Date().getTimezoneOffset()

plugins

Проверяется список этих плагинов:

Проверяется, что mimetype'ы соответствует своим плагинам:

Проверяется navigator.plugins.item, о котором мы говорили в прошлой статье.

Проверяется, что refresh это свойство PluginArray, которое configurable: true, writable: true:

navigator.plugins.refresh = 'somevalue';
navigator.plugins.refresh === 'somevalue' ? ...

Ещё немного проверок свойств автоматизируемых браузеров:

selen&CO
var hsE = Gh.slice();
var FsE =
  win.Boolean(win.window.__nightmare) +
  (win.Boolean(win.window.cdc_adoQpoasnfa76pfcZLmcfl_Array) <<
    Fh);
var WsE;
return (
  (FsE +=
    (win.Boolean(
      win.window.cdc_adoQpoasnfa76pfcZLmcfl_Promise
    ) <<
      nF) +
    (win.Boolean(
      win.window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol
    ) <<
      IW)),
  (FsE +=
    (win.Boolean(win.window.OSMJIF) << NF[OF]) +
    (win.Boolean(win.window._Selenium_IDE_Recorder) <<
      NF[hh])),
  (FsE +=
    (win.Boolean(win.window.__$webdriverAsyncExecutor) <<
      bF) +
    (win.Boolean(win.window.__driver_evaluate) << Ph)),
  (FsE +=
    (win.Boolean(win.window.__driver_unwrapped) << DF) +
    (win.Boolean(win.window.__fxdriver_evaluate) << Fk)),
  (FsE +=
    (win.Boolean(win.window.__fxdriver_unwrapped) << hh) +
    (win.Boolean(win.window.__lastWatirAlert) << NF[AF])),
  (FsE +=
    (win.Boolean(win.window.__lastWatirConfirm) << nC) +
    (win.Boolean(win.window.__lastWatirPrompt) << JF)),
  (FsE +=
    (win.Boolean(win.window.__phantomas) << wq) +
    (win.Boolean(win.window.__selenium_evaluate) << bG)),
  (FsE +=
    (win.Boolean(win.window.__selenium_unwrapped) << cF) +
    (win.Boolean(win.window.__webdriverFuncgeb) << SF)),
  (FsE +=
    (win.Boolean(win.window.__webdriver__chr) << CC) +
    (win.Boolean(win.window.__webdriver_evaluate) << kF)),
  (FsE +=
    (win.Boolean(win.window.__webdriver_script_fn) <<
      EE.sExI()) +
    (win.Boolean(win.window.__webdriver_script_func) << GF)),
  (FsE +=
    (win.Boolean(win.window.__webdriver_script_function) <<
      EE.sExx()) +
    (win.Boolean(win.window.__webdriver_unwrapped) << zl)),
  (FsE +=
    (win.Boolean(win.window.awesomium) << hl) +
    (win.Boolean(win.window.callSelenium) << pJ)),
  (FsE +=
    (win.Boolean(win.window.calledPhantom) << Sl) +
    (win.Boolean(win.window.calledSelenium) << KF)),
  (FsE +=
    (win.Boolean(win.window.domAutomationController) << kh) +
    (win.Boolean(win.window.watinExpressionError) << YW)),
  (FsE +=
    (win.Boolean(win.window.watinExpressionResult) << Xr) +
    (win.Boolean(win.window.spynner_additional_js_loaded) <<
      NF[nC])),
  (WsE = FsE +=
    (win.Boolean(win.document.$chrome_asyncScriptInfo) <<
      YF) +
    (win.Boolean(win.window.fmget_targets) << NF[JF]) +
    (win.Boolean(win.window.geb) << TF)),

navigator.webdriver

И снова webgl

К тому, что было, ещё собирается массив getSupportedExtensions().

Базовый фингерпринт на то, как рендерится у вас текст в браузере:

Шрифты

Обычно собирается до кучи для идентификации пользователя. На MacOS вы получите один хэш, на Windows другой. Да и от ПК к ПК хэши будут отличаться в каких-то случаях.

Проверка поддержки webrtc в вашем браузере:

'function' == typeof win.window.RTCPeerConnection ||
'function' == typeof win.window.mozRTCPeerConnection ||
'function' == typeof win.window.webkitRTCPeerConnection),

Ещё сбор свойств навигатора:

Navigator

Ещё проверка свойств:

some props

И на этом, пожалуй, стоит остановиться...

Что к чему?

У акамая достаточно сложная динамическая обфускация, но достаточно незрелые методы проверки автоматизации браузера. Да, он увидит ваш селениум "из коробки" или сырой headless браузер, но если вы захотите обойти проверки на автоматизацию или подделать разрешение экрана с другими свойствами для изменения браузерного отпечатка, то этого не составит труда провернуть. На просторах интернета есть CreepJS, у которого проверки на автоматизацию и эмуляцию гораздо интереснее.

Вся эта обфускация существует с целью усложнить вам создание гена(Akamai Sensor Data Generator). Но уже сейчас при желании его можно написать.

Да, мы не знаем как акамай анализирует данные на стороне сервера, ведь помимо JavaScript, есть много других вещей, таких как ip, TLS, Ja3 и так далее... Но существуют некоторые возможности подстроить ваши запросы под браузер.

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

Файлы

GitHub

Теги:
Хабы:
Всего голосов 106: ↑106 и ↓0+106
Комментарии37

Публикации

Истории

Работа

Ближайшие события