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

Комментарии (36)


  1. Areso
    00.00.0000 00:00
    +16

    Хабр торт!


    1. Lizdroz
      00.00.0000 00:00

      Пончики я люблю больше


  1. akakoychenko
    00.00.0000 00:00
    +15

    Кажется логичным для таких штук использовать самописное расширение к самому обыкновенному Хрому, чтобы сразу пройти мимо всех попыток зацепиться за какую-то несущественную мелочь в интерпретации кода.

    как вы перемещаете мышь, с какой скоростью нажимаете-отпускаете клавишу при наборе текста

    Тут у меня самого был забавный кейс. Пытался лет 5 назад отловить ботов на сайте букмекера по эвристикам в поведении, и самая большая вероятность вышла у игроков, которые явно не были ботами, но много ставили на киберспорт. В принципе, все логично. Фанаты киберспорта это люди, которые умеют быстро и эффективно использовать мышь и клавиатуру + с высокой вероятностью сидят на сайте с мощного компьютера с хорошим интернетом. Поэтому, ещё вопрос, кто быстрее, - они, или бот, крутящийся на виртуалке за $5/мес, и сидящий на сайте через прокси)

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

    А это, к слову, очень интересный вопрос. В нем кроется причина того, почему 3-rd party антифроды, как правило, достаточно неэффективное говно в сравнении с хорошим in-house. Ключ к качественной разметке кроется в доступе к транзакционным данным бизнеса. Если у тебя есть 1 млрд пользовательских сессий без связей между собой, то превратить их в разметку достаточно сложно. Но, когда к каждой из этих сессий привязана полная история пользователя, то уже на основе долгосрочной истории пользователя понять, попадает он в категорию "точно бот или фродер", "точно не бот", "мало данных -> из выборки лучше выкинуть" намного проще.

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


    1. Robastik
      00.00.0000 00:00

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

      Откуда вывод о том, что банятся редкие конфиги?


      1. akakoychenko
        00.00.0000 00:00
        +3

        В этом суть подобных систем. Не видел, как работает именно эта изнутри, но видел подобные (которые собирают достаточно большой вектор из параметров браузера, начиная от очевидных, вроде разрешения экрана, и заканчивая странными, вроде результата исполнения фрагмента кода, который нарушает стандарт js, после чего на сервере пытается классифицировать бот/не бот).

        Настроить такую систему (что машинным обучением, что эвристиками) без false positive невозможно. Никогда не знаешь, один из индикаторов отклонился от кластера легитимных юзеров потому, что ботовод забыл его корректно сделать, или потому, что у человека редкий конфиг.


        1. rastvl Автор
          00.00.0000 00:00

          вроде результата исполнения фрагмента кода, который нарушает стандарт js

          Если не ошибаюсь, то бет365 когда-то давно подобным промышлял, хотя может чего путаю... Тем не менее, у этой БК в своё время были самые весёлые обнаружения ботов, например, метод querySelectorAll(), который использовался ботами для поиска элементов, они переопределяли так, что при его использовании запускался бесконечный цикл и браузер падал)

          А так да, у антиботов бывает достаточно ложных срабатываний даже на обычных пользователей. Именно поэтому нам иногда приходится прокликивать те капчи, которые для нас не предназначены. Та же капча от PerimeterX(press and hold) зачастую срабатывает на юзерах, даже если сам антибот настроен на "средние" проверки.


  1. vilgeforce
    00.00.0000 00:00
    +1

    Сурово! Снимаю шляпу :-)


  1. savostin
    00.00.0000 00:00
    +2

    Эх, еще бы понять как Cloudflare определяет ботов. У меня только этим получилось его пробить. Но как оно работает, слабо понимаю.


    1. GennPen
      00.00.0000 00:00
      +5

      Было похожее, в предыдущей публикации автора: Chrome Headless против cloudflare JS challenge / Хабр (habr.com)


  1. Robastik
    00.00.0000 00:00
    +1

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

    Всегда так было и вот опять?

    Но на каких данных обучается ИИ, если антибот не способен отделить пользователя от бота?

    Разметкой же не боты занимаются?

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

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

    Вот где в вашем реинженеринге это предположение?

    ***

    К чему вся эта детективная история, если никаких выводов по существу не получено? Нельзя же считать результатом вывод о том, что антибот собирает отпечатки?)

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


    1. rastvl Автор
      00.00.0000 00:00
      +2

      Всегда так было и вот опять?

      Не всегда. Вернее не только. Достаточно посмотреть в сторону какого-нибудь Shape Security или Kasada хотя бы, которые проводят более глубокие проверки на эмуляцию параметров. Кстати, ботгуард гугла даже этим занимается, хотя кому-кому, а вот ему-то вообще на такое внимание обращать не нужно)

      Тому же яндексу(о нём ещё поговорим скорее всего) тоже бы не помешало повнимательнее смотреть за эмуляцией. Потому как одни накручивают ПФ, а другие вынуждены платить за какую-нибудь рекламу, чтобы поднять свой сайт повыше.

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

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

      Ну вот один, не совсем аккуратный(ну или не парится просто), скачивает, а другой тихо сидит в сторонке, не отсвечивает и "нагуливает" историю, чтобы закосить под человека, а в нужный момент берёт и выкупает товар, который только-только появился "на полке". В это же мгновение подтягиваются подобные ему друзья и за 10 минут сметают всё. Это я утрирую, конечно, но всё-таки

      Нельзя же считать результатом вывод о том, что антибот собирает отпечатки?)

      Мне кажется, это к акамаю больше претензия) Я же не виноват, что он ничем более с клиентской стороны через JS не занимается.

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

      Подделка отпечатков все равно делается без оглядки на то, как их тестируют те или иные антиботы.

      Кто их так подделывает? Если бы антидетект браузеры их так подделывали, то никто бы ими не пользовался, а пользовались бы обычными расширениями. Зачем подделывать, условно говоря, только screen.width, если антибот может сравнить это дело с CSS Media Queries через MediaQueryList.prototype.matches(), а ещё посмотреть ширину document.body.clientWidth или отрисовать что-нибудь во всю ширину и взять значение? Плюсом ко всему, через JS можно обнаружить и наличие автоматизации, то есть самой CDP-сессии, которая и управляет что selenium'ом, что playwright'ом, что puppeteer'ом. Нет смысла просто эмулировать отпечатки ради отпечатков. Нужно ещё и озаботиться тем, чтобы их эмуляция не была обнаружена, а для этого не помешает "почитать" скрипты антиботов, ради которых, собственно, отпечатки и подделываются.


      1. Robastik
        00.00.0000 00:00

        Ваш глубокий системный подход в домене js удивительно контрастирует с подходом к анализу защиты на основе идентификации пользователя.

        проверки на эмуляцию параметров

        Это же другое. Море народа парсит по учебникам 10-летней давности на curl и python. То есть для защиты от 95% фрода достаточно посмотреть маркеры реальности браузера. Если кто-то лезет неприкрыто селениумом или прочими playwright`ами, то никакие отпечатки не нужны, и так же уши торчат. Ну то есть это начальный уровень защиты, когда "здрасьте, я не браузер" или "а я уже не curl, я состоявшийся селениум".

        Просто вы встретили в одном продукте обе технологии и почему-то смешали теплое с мягким.

        сам процесс деобфускации

        Это чистое искусство, да. Как собирать 5 кубиков рубика, жонглируя ими. Респект и уважуха!

        Кто их так подделывает?

        В смысле? Кому данные нужны, те и подделывают. Например, первый в гугле.

        антидетект браузеры их подделывали

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

        Зачем подделывать только screen

        Я не настоящий сварщик и не знаю → при какой атаке на данные актуальны параметры экрана?

        Как бы направление мысли вроде улавливаю, но пример (наверное) не актуальный.

        Почему бы просто не отказаться от хедлес и не показать честно размер своего экрана? Может вам, как человеку искусства, принципиально обманывать антибота во всем, но практический смысл этих усилий мне недоступен.

        Да, без хедлес дороже. Но в сравнении с затратами на загрузку графики это незначительные затраты. А загрузка графики это мастхев, без нее вменяемые юзеры не серфят, только боты.

        самой CDP-сессии

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

        чтобы их эмуляция не была обнаружена

        Вообще, от статьи такое впечатление, что вы выше этого.

        Эмуляция должна быть необнаружима в принципе. А не в текущем варианте конкретного антибота. Завтра придет второй, третий, пятый, каждый день будете эмуляцию изобретать? Антиботы же не совсем дураки пишут, что, они не догадаются как их будут обманывать?

        P.S.

        "нагуливает" историю

        Вы же понимаете, что 1) история - это один из десятков штрихов отпечатка и кардинально ситуацию не меняет и 2) нагулянная история, выбивающаяся из статистики на территории выбранного IP - тоже вполне себе маркер.

        и за 10 минут сметают всё

        Это идеальные клиенты маркетплейса.

        Вы правда думаете, что магазины борются с покупателями, которые покупают без затрат времени и рекламных бюджетов?


        1. rastvl Автор
          00.00.0000 00:00

          В смысле? Кому данные нужны, те и подделывают. Например, первый в гугле.

          Мы, видимо, друг-друга не совсем понимаем и говорим о разных вещах. По вашей же ссылке и располагает то, о чём я говорил - не эмуляция ради эмуляции, а такая эмуляция, чтобы её было сложнее обнаружить. Посмотрите в репозитории на то, как переопределяются объекты. Вместо того, чтобы просто "прописать" им заранее собранные значения, используется вагон логики, который пытается скрыться от антиботов: переопределение toString(), обработка стека ошибок, правильный триггер ошибок в зависимости от вызова геттера. Например, если я переопределю свойство navigator.webdriver так(чтобы попытаться себя не обнаружить):

          Object.defineProperty(Navigator.prototype, 'webdriver', {
            get: () => false
          })

          То смогу вызвать геттер следующим образом:

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

          error

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

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

          Я не настоящий сварщик и не знаю → при какой атаке на данные актуальны параметры экрана?

          Как бы направление мысли вроде улавливаю, но пример (наверное) не актуальный.

          Да, пример абстрактный. Правильнее использовать своё разрешение и не беспокоиться ни о чём. Но даже здесь можно допустить ошибку, если я решу выставить в playwright разрешение 1920x1080 следующим образом:

          const context = await browser.newContext({
            viewport: {
              width: 1920,
              height: 1080
            }
          });

          В таком случае параметр screen.height будет равен screen.availHeight, что не является правдой для обычного хрома, и меня обнаружат.

          Эмуляция должна быть необнаружима в принципе. А не в текущем варианте конкретного антибота. Завтра придет второй, третий, пятый, каждый день будете эмуляцию изобретать? Антиботы же не совсем дураки пишут, что, они не догадаются как их будут обманывать?

          Достаточно посмотреть ветку знаменитого стелс плагина, чтобы понять, что очень сложно сделать "не обнаруживаемую эмуляцию". Как там выражаются: "это бесконечная игра в кошки-мышки". Сегодня антиботы обнаруживают одно, завтра их обходят, они обнаруживают другое, их снова обходят и так далее...

          Вы правда думаете, что магазины борются с покупателями, которые покупают без затрат времени и рекламных бюджетов?

          Иногда боты вредят бизнесу. Ну и вот статейка есть об этом


          1. interprise
            00.00.0000 00:00

            Получается без патча самого хромиума, нет вообще вариантов спрятать webdriver?


            1. ionicman
              00.00.0000 00:00

              Есть, но придётся очень сильно заморачиваться + поддерживать это решение. Поэтому проще либо юзать патченное, либо вообще реальный браузер через расширение.


              1. rastvl Автор
                00.00.0000 00:00

                Если речь идет об этом расширении, то тоже следует быть аккуратным. Он переопределяет некоторые методы в объекте window(alert(), prompt()), также добавляет функцию getFrameLocation(), которой изначально в браузере нет.

                Да, вас не забанят, в привычном понимании этого слова, но пару лишних капч могут попросить прокликать)


            1. rastvl Автор
              00.00.0000 00:00

              Да, правильно написал @ionicman. Можно и через JS "спрятать", но это потребует немалых трудов, порой очень немалых. Патченный хромиум тоже не панацея, ведь постоянно появляются новые способы обнаружения.

              Плюс переопределения методов через JS как раз в том, что такое решение проще поддерживать при обновлении движка браузера. Но, если честно, селениум "из коробки" я бы для обхода антиботов не использовал. Слишком уж он отличается от обычного браузера. А ваша задача не "быть уникальным", а "быть как все".

              Стоит сейчас обратить внимание на новый headless режим.


              1. Robastik
                00.00.0000 00:00

                 новый headless

                В чем разница? Там не написано вроде.

                В любом случае, хедлес маркер бота. Хоть новый, хоть старый.


                1. rastvl Автор
                  00.00.0000 00:00

                  В чем разница? Там не написано вроде.

                  Вот материал есть, если самому в хромиум лезть не хочется

                  В любом случае, хедлес маркер бота. Хоть новый, хоть старый.

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


                  1. Robastik
                    00.00.0000 00:00

                    I’m not going to share any new detection signals as I used to do.

                    Вот жук)))

                    Вот материал есть

                    Спасибо огромное!

                    У людей разные задачи

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


                    1. rastvl Автор
                      00.00.0000 00:00

                      Вот жук)))

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


            1. MaksQ
              00.00.0000 00:00

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


          1. Robastik
            00.00.0000 00:00

            Посмотрите в репозитории

            Вот там я вижу, что наивный подход с переопределением - это подход давно минувших дней.

            Довольно быстро до участников проекта дошло, что

            скрыться от антиботов: переопределение toString()

            → очень глупо. Т.к. если атака держится на toString, то вместо нее будет использоваться custom версия, которая будет подгружаться в случайном месте в случайное время под случайным названием.

            То есть поддерживать переопределение → очень трудозатратно.

            И перешли к другому варианту:

             другая версия с патченным хромиумом

            В этом варианте js видит ровно то, что должен видеть. Никакого больше геморроя.

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

            Ну а школота, она продолжает трахаться с переопределением) Потому что денег на профессиональные инструменты не имеет.

            И вот это

            игра в кошки-мышки

            относится не к переопределению. Это уже к тому, что еще можно включить в отпечаток. Запатчили хедлес, запатчили вебдрайвер? Но придумали идентифицировать по canvas. Запатчили канвас → стали смотреть набор шрифтов. Потом смайлики. Но не 100500 вариантов спрятать toString)))


            1. rastvl Автор
              00.00.0000 00:00

              Вот там я вижу, что наивный подход с переопределением - это подход давно минувших дней.

              Само собой, но антиботами до сих пор это проверяется, а людьми используется

              И перешли к другому варианту:

              И мы переходим к тому, что для того, чтобы что-то пропатчить, нужно знать что именно патчить. Потому что одни и те же штуки через JS можно просмотреть разными способами. И эта другая версия с патченным хромиумом светится только в путь. Да, не toString, но другими вещами

              Никакого больше геморроя.

              Его никогда в таких вещах меньше не становится)

              Это уже к тому, что еще можно включить в отпечаток.

              Вот из статьи прекрасно видно, что именно акамай включает в отпечаток. Правда за пару лет мало что изменилось...

              Но не 100500 вариантов спрятать toString)))

              Да я для примера... Их здесь вагон можно привести


              1. Robastik
                00.00.0000 00:00

                это проверяется, а людьми используется

                Это вы про стелс плагин называете "используется"? Как видно из количества незакрытых багов, подход с переопределением давно не работает и работать больше никогда не будет. Этот вектор атаки полностью блокирован.

                версия с патченным хромиумом светится

                Насколько я понимаю, js может увидеть только то, что показывает ему браузер. В нормальной ситуации браузер передает в js либо то, что ему отдает Win API (пример - размер экрана), либо то что сам посчитал (пример - юзерагент).

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

                Что же там светится?

                за пару лет мало что изменилось

                Может просто дискуссия между нападением и обороной ушла из публичного поля? Тех, кто способен патчить хромиум, их мало и далеко не все хотят делиться опытом. Чаще они хотят его продавать) Тем более ставки серьезно выросли после того, как школота со стелс плагином и прочими фейкбраузерами отвалилась.


                1. rastvl Автор
                  00.00.0000 00:00
                  +1

                  Это вы про стелс плагин называете "используется"?

                  Сообщество живое, вопросы регулярно задаются. Да, используется.

                  Как видно из количества незакрытых багов, подход с переопределением давно не работает и работать больше никогда не будет.

                  Я могу применяя лишь один JS обойти и клаудфлеер, акамай, и имперву, и периметр, и шейп...

                  Этот вектор атаки полностью блокирован.

                  Его никогда не заблокировать. Может быть в контексте стелс плагина всё и заблокировано, но как подход - нет.

                  То есть при надевании чужого отпечатка js вроде бы не имеет инструментов для проверки того, что браузер подделал все

                  Если движок вашего хромиума 110, а вы "надели" чужой отпечаток от хромиума 109, то обнаружить это раз плюнуть. Например, достаточно посмотреть свойства объекта window - Object.getOwnPropertyNames(window). От версии к версии набор будет отличаться. Сюда можно приплести ещё много чего, те же CSS стили.

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

                  Что же там светится?

                  В контексте того репозитория и использования хромиума с патчами вместе с puppeteer всякие штуки по типу Runtime.Enable, Network.Enable и прочее очень просто обнаруживаются. Ну и само собой то, что я только что написал. Там во многих уклонениях сделана треть(а то и меньше) от того, что нужно.

                  Может просто дискуссия между нападением и обороной ушла из публичного поля?

                  Нет, эта дискуссия живее всех живых. Достаточно почитать сообщества разработчиков ботов для тех же кроссовок.

                  Тех, кто способен патчить хромиум, их мало и далеко не все хотят делиться опытом.

                  Я вот не хочу, да. Тем не менее, материалов для обучения достаточно.

                  школота со стелс плагином и прочими фейкбраузерами отвалилась

                  Я всё-таки ещё раз хочу заметить, что у людей разные задачи. Кому-то и стелс плагин всё ещё подходит. А кому-то изначально не подошёл, но он сам пофиксил в нём нужные баги и пользуется. И всех их называть "школотой" как-то не очень. Эта "школота" свои сайты в топ в том же яндексе накруткой продвигает.


                  1. Robastik
                    00.00.0000 00:00
                    +1

                    Спасибо вам большое, познавательно.


  1. guvernir4
    00.00.0000 00:00
    +1

    Написать свой интерпретатор JS - снимаю шляпу. Я тоже раньше пытался это сделать. Знаю насколько это высокоинтеллектуальная работёнка)

    И да, поддерживаю, побольше бы таких статей на хабре и хабр был-бы торт.


    1. rastvl Автор
      00.00.0000 00:00
      +1

      Знаю насколько это высокоинтеллектуальная работёнка)

      Соглашусь с тем, что написать хороший AST интерпретатор это задачка со звёздочкой, но приведённая мной реализация интеллектом вроде не очень пахнет)

      Спецификация EcmaScript охала бы и ахала, если бы видела, как моё творчество скрипт выполняет


  1. DrMefistO
    00.00.0000 00:00
    +2

    Проделана гигантская работа! Спасибо:) Писали как-то с товарищами декомпилятор байткода nodeJS - знаю о чём говорю.


    1. rastvl Автор
      00.00.0000 00:00
      +2

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


    1. Lizdroz
      00.00.0000 00:00

      Вау, не представляю насколько это тяжело было.


  1. WondeRu
    00.00.0000 00:00
    +1

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


  1. demimurych
    00.00.0000 00:00
    +2

    Важно было бы сказать, что реализуемая машина, крайне далека от той, которая удовлетворяет современной спецификации. Что заявленный материал, ставит перед собой задачи описать суть, пусть и с изрядно долей допущений, но в рамках спецификации.

    Мне доставило большое удовольствие комментировать этот материал, потому, что несмотря на ряд фундаментальных ошибок, он, тем не менее, пытается общаться грамотным языком спецификации языка JS, но не на языке выдуманных абстракций подобных областей видимости, хостинга, контекста this и прочей чепухи которых в JS нет.

    Под спойлером часть комментариев по сути сказанного

    Да. Мы сами обойдём дерево и выполним в нём каждый узел. Если внимательно посмотреть на скрипт, то в нём используется некоторое подмножество JS. Вы, может, заметили, что там какой-то ES3 с var-переменными, без всяких новомодных rest-spread операторов, стрелочных функций и так далее.

    А еще можно было бы воспользоваться DevTools протоколом, который предоставляет тот самый DevTools. Впрочем это лишило бы нас удовольствия наконец узнать как именно JS описан в спецификации.

     

    Чтобы выполнить узел VariableDeclaration

    Узел VariableDeclaration это `foo = 10`, но не `var foo = 10`
    Более того, чтобы было яснее, например в строке const foo = 10 будет ровно такое же VariableDeclaration как и в строке var foo = 10 как и в строке let foo = 10

       

    ищем имя в текущей области видимости, если имя существует, то получаем значение по этому имени, а если не существует

    Это требует особых пояснений. Потому, что многие привыкли думать, будто бы значение undefined или null эквивалентно `не существует`.

     

    Если переменная не найдена и там, то мы получаем знаменитую ошибку "ReferenceError - variable "foo" is not defined".

    Мы получаем ошибку Uncaught ReferenceError: foo is not defined.
    Никаких сообщений про variable там нет и быть не может.

     

    record - это хранилище для переменных. Представляет собой объект, ключи которого являются именами переменных, а значения - данные, которые связаны с этими именами. parent - ссылка на родительское окружение

    Рекорд это запись которая хранит в том числе имена идентификаторов. Кроме этого это запись которая хранит большое количество вспомогательной информации.

     

    Мы просто в текущий объект record добавляем пару имя-значение.

    Это неверно.

     

    Финальный код класса Environment

    О котором обязательно стоит сказать, что он работает правильно исключительно в случае variableStatement + AssigmentExpression и StatementList.
    И совершенно неверно работает при любых LexicalDeclaration (о чем сказано было автором в начале материала) и StatementList как Statement от FunctionStatementList в случае если это Block Statement (о чем автором было не сказано и ниже в совем материале он совершенно запуталася в этом).

     

    Контекст исполнения

    Это абстрактное понятие, которое используется для обозначение того, какое Enviroment в настоящий момент используется как текущее. Что Вы и сделали.

     

    Наш контекст будет содержать два свойства - thisValue и, собственно, Environment

    И тут вы ошибаетесь, потому, что в вашем случае this зависит только от execution context, что справедливо для strict mode. В non strict this может зависеть от Object Environment или with statement.

     

    Да, это не мираж. thisValue ссылается на window, а поле record класса GlobalEnvironment тоже инициализировано window. Так оно в JavaScript и работает.

    Нет, оно работает в JS не так. ВЫ описали частный случай, который в большинстве случаев будет удовлетворять условиям до ES5.
    Когда появились LexicalDeclaration, логика поведения в рамках Global Environment Record сильно изменилась: Любая Lexical Declaration попадает в Lexical Enviroment Record текущего Execuion Context. И никогда не будет отображена в Globlal Object. Простое доказательство этому:

    let theLet=10;
    window['theLet']; // undefined
    var theVar=10; 
    window['theVar']; // 10

     

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

    В спецификации обозначается как assignment expression, и это совсем не то что написал автор руководства:

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

    это все не assignment expression. 1 строка - это variable statement c variable expression с initializer. Второе это variable expression + initializer; третье это просто RunTime semantics для идентификатора.

    Семантика Assigmetn Operator описана вот тут

     

    ThisExpression ObjectExpression ArrayExpression

    Ничего подобного в спецификация языка JS (Ecma) нет и быть не может. В данном случае это следует понимать как определенный уровень упрощения, и не ждать соответствия спецификации.

     

    ConditionalExpression

    никак не соответствует заявленной в начале руководства цели <ES5. Что просто нужно интерпретировать как то факт, что на самом деле автор говорил о некотором достаточном SCOPE Ecma Statement & Declaration, но не как о четком следовании стандарту.

     

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

    Идентификаторы заявленные благодаря var statemant никакого отношение к hoistable не имеют. Hoistable это исключительно function declaration, generator declaration и их async вариации.

     

    Создадим отдельный метод для подъёма переменных:

    Не существует подьема переменных. Тут у автора случилась каша в голове между ES5, strict mode и тем что действительно называется hoistable.

    Термин hoistable относится только к функциям или генераторам. И действительно имеет разное поведение в зависимости от FunctionStatemantList и BlockStatemant вне зависимости от Strict mode, но в зависимости от того какой стандарт мы используем.

    В случае ES5+ hoistable в рамках function statemant list приведет к генерации в function enviroment record имени идентификатора без привязывания его к функциональному обьекту в том случае, если он окажется внтури block statemant.
    Убедиться в этом легко на простом примере:

    ( 
      function doTest() {
    	doTest2()
    	{
    		function doTest2() {}
    	}
      } 
    )() //Uncaught TypeError: doTest2 is not a function
    

    Что говорит о инициализации идентификатора, но отсутствии привязки его к функциональному объекту.

     

    Это важно! Мы должны просто добавить переменные в текущее окружение, но не нужно присваивать им никакие значения.

    Это очередное упрощение, не соответствующее спецификации. Связывание идентификатора с undefined - уже есть связывание со значением.

    Формулировка же - создать идентификатор, но не связывать его со значением, действительно появилась с ES5+ и именно это и обозначает.

    Инициализация же идентификатора, в том числе и primitive vale undefined происходит тут

     

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

    Такой код должен быть обработан действительно потому, что exucation context создается только для hoistable declaration (потому они так и называются) И ошибка в вашем коде, связана с тем, что вы все перепутали. Потому, что в ES5+ BLockStatemant создает только LexicalEnviroment и только в случае RunTime семантики. То есть то, что Вы нагородили в своем коде никакого отношения к спецификации не имеет. НО прекрасно работает в Вашем случае.

     

    Что такое функция? Это именованный блок кода, который можно параметризировать какими-нибудь параметрами. Грубо говоря, мы хотим сохранить набор инструкций под определённым именем, а затем в какой-то момент начать его выполнение.

    Тут автора понесло. Все что он сказал является почти чепухой. Функция в JS это конструкция которая в случая RunTime Semantics обязательно создает Execution Context в рамках которого и происходит выполнения кода.

    То что описал автор, может быть создано в рамках Labeled Block Statemant. При этом Runtime Sematics такого кода никогда не приведет к созданию Execution Context, но приведет к созданию новой Lexical Environment, которая заменит существующую в текущем Execution Context.

     

    ExecutionContext, как мы помним, состоит из значения thisValue, а также окружения - Environment. Перед выполнением тела функции это окружение заполняется переданными аргументами

    Execution Context не имеет никакого отношения к this. К неявной установке this имеет отношение только RunTime Semantica для MemberExpression в случае CallExpression. Environment действительно имеет связь с this но только для случая использования with statement.

     

    Нам осталось реализовать ReturnStatement. На самом деле он прост, но есть нюанс. После выполнения оператора return выполнение блока кода должно остановиться. Но блоков может быть вагон

    Тут автор окончательно запутался, не понимая в чем разница между Return Statement и Evaluation RunTime Shematics для BlockStatemant.

     

    И так далее и тому подобное.

    Автор материала доставил мне большое удовольствие, за возможность поговорить на языке спецификации в рамках языка JavaScript.

    И я уверен, что если плотнее поговорить с автором руководства, то все недоразумения обозначенные мной выше, были бы гарантировано приведены в порядок. Уже хотя бы потому, что автор ставил себе задачей объяснить суть глазами спецификации, что ему прекрасно удалось.


    1. rastvl Автор
      00.00.0000 00:00

      Спасибо за такой развернутый ответ !

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

      Вообще, в изначальной версии статьи всё это было) Потом исчезло, потому что не хотелось пугать читателей тем, что мы будем писать какой-то там интерпретатор. Нужно было хоть как-то прокрутить скрипт.

      Но я оставил фразу

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

      Названия же узлов и конструкции были просто взяты из парсера babel, который для удобства использования добавляет некоторую отсебятину. Задача была в том, чтобы люди поняли что откуда берётся и почему так написано. В конечном счёте, как мне кажется, это получилось, потому что написание всего кода свелось к системе "посмотреть в astexplorer-перенести на бумагу"

      Моя ошибка, видимо, в том, что я использовал "слова" из спецификации. Не нужно было и этого делать.

      А еще можно было бы воспользоваться DevTools протоколом, который предоставляет тот самый DevTools.

      Это отличный вариант(лучше нашего), но рассказ про применение CDP к деобфускации я припас на потом, а в этом материале хотелось сохранить более выраженную связь с предыдущей статьёй.


      1. demimurych
        00.00.0000 00:00

        Я приблизительно так и предположил. Потому стартлся лишний раз подчеркнуть, что мои _заявления о проблемах соответствия_ с высокой долей вероятности связаны именно с єтим.

        Тем не менее, еще раз спасибо Вам за материал.