Набор инструментов для обратного проектирования виртуальной машины байт-кода PerimeterX, включающий дизассемблер на основе CFG, 5-уровневый конвейер дешифрования, восстановление таблицы опкодов и очиститель эмуляции стека для исследований в области безопасности, связанных с fingerprinting обнаружения ботов.
В этом репозитории документируется обратная разработка auditor.js от PerimeterX — байткод-виртуальной машины, используемой в качестве вторичного уровня снятия отпечатков в пайплайне обнаружения ботов PX. Анализ охватывает:
Примечание: Этот репозиторий охватывает только одну (статическую) версию VM и предназначен для целей исследований в области безопасности. Он не включает динамические решатели или реализации продакшн-решателей.
В четверг, 2 апреля 2026 года, PerimeterX развернула новую байткод-виртуальную машину в рамках своего пайплайна обнаружения ботов.
auditor.js не похож на обычный сенсорный скрипт PX. Вместо привычных обфусцированных поисков свойств и функций-сборщиков:
_fg0 – _fg7) — зашифрованная программа VM, разбитая по переменным_dp) с посайтовым ключом (_pk), распаковывающая JSON программы_0x8df7), хэширующий исходный код самой VM для вывода ключа дешифрования, так что любое изменение молча ломает дешифрование байткодаПрограмма VM разбита на 8 переменных, конкатенируется, а затем дешифруется функцией _dp() с использованием посайтового XOR-шифра, ключом которого является _pk:
var _pk = 893686289;
function _dp(_b) {
var _r = atob(_b), _o = new Array(_r.length);
for (var _i = 0; _i < _r.length; _i++) {
_o[_i] = String.fromCharCode(
_r.charCodeAt(_i) ^ (((_pk >>> (8 * (_i % 4))) ^ Math.imul(_i + 1, 0x6B8B4567)) & 0xFF)
);
}
return _o.join("");
}
Результат — объект JSON с обфусцированными двухсимвольными именами ключей (например, "uo" для seed, "dk" для nonce). Таблица соответствия преобразует их в стандартные имена.
node extractor.js
# -> program.json
| Поле | Описание |
|---|---|
s | Seed (12755), управляет всеми криптографическими операциями |
n | Nonce (1603730985), рандомизация для каждой программы |
g | Флаг генератора, включает уровень дешифрования с хэшем целостности |
x | Зашифрованный флаг, константы зашифрованы XOR |
c | Пул констант, 1230 записей |
f | Функции, 112 записей с зашифрованным байткодом |
e | Точка входа, индекс функции 0 |
Все 1095 строковых констант зашифрованы двумя уровнями:
Уровень 1: Статический murmur XOR с ключом 4008000571, зависит от позиции.
Уровень 2: XOR с потоком PRNG, использующим glibc LCG; seed формируется комбинацией seed программы с индексом каждой константы через мультипликативное хэширование Кнута.
Перед дешифрованием seed XORится с отпечатком окружения (_0xaf48) — 8-битной битовой маской, вычисляемой путём зондирования API браузера:
| Бит | Тест | Chrome |
|---|---|---|
| 0 | typeof window.matchMedia === "function" | 1 |
| 1 | document.elementFromPoint существует | 1 |
| 2 | typeof window.requestAnimationFrame === "function" | 1 |
| 3 | typeof window.getComputedStyle === "function" | 1 |
| 4 | CSS.supports существует | 1 |
| 5 | navigator.sendBeacon существует | 1 |
| 6 | document.execCommand существует | 1 |
| 7 | process.versions.node существует (Node.js) | 0 |
Для Chrome: _0xaf48 = 0b01111111 = 127, что даёт эффективный seed = 12755 ^ 127 = 12716.
Это означает, что одна и та же программа даёт разные результаты дешифрования в разных средах. Запуск в Node.js, Chrome и Firefox приводит к разным seed.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
Расшифрованные строки точно говорят нам, какие отпечатки снимает VM:
Снятие отпечатков браузера: screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
Тайминги производительности: navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
Криптография RSA: BigInt, modPow, AQAB (65537 в base64), modulusLength, shiftLeft, shiftRight, getRandomValues
Зондирование DOM/SVG: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
Имена полей PX: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, ads, enve, init
Ссылки на конечные точки: https://fst-ec.perimeterx.net/?id=
Анти-отладчик: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 базовых опкодов, покрывающих весь язык JavaScript, плюс динамически генерируемый шум:
40 медовых опкодов — альтернативные реализации арифметических/сравнительных операций с математически эквивалентными, но синтаксически различными выражениями. ADD может выглядеть как (a^b) + 2*(a&b) или -((-a)-b) или a-(-b). Каждый базовый опкод может иметь до 3 вариантов, генерируемых детерминированно из seed. Простая инструкция ADD может появиться как 4 разных значения байткода в одной программе, ломая подходы на основе поиска шаблонов.
24 заполняющих опкода выделяются в перестановке, но не имеют обработчиков и никогда не генерируются. Они существуют для расширения пространства опкодов и усложнения инвертирования перетасовки.
16 групп суперинструкций — самая важная антианалитическая особенность. Когда цикл диспетчеризации разрешает опкод в лидер суперинструкции, обработчик читает один дополнительный байт из потока байткода и диспетчеризирует к подобработчику. Подобработчик может быть совершенно другой операцией:
| Лидер разрешается как | Подбайт | Фактически выполняется |
|---|---|---|
FOR_IN_NEXT | 74 | FOR_IN_NEXT |
FOR_IN_NEXT | 100 | MAKE_CLOSURE |
ASSIGN_OP_VAR | 165 | ASSIGN_OP_VAR |
ASSIGN_OP_VAR | 37 | JMP |
GET_VAR_PROP_C | 143 | SET_VAR_POP |
GET_VAR_PROP_C | 23 | JMP_NULLISH |
Таблица опкодов перетасовывается с помощью Фишера-Йетса, инициализированного эффективным seed, так что значения байткода различаются в разных сборках.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
Ядро инструментария. cfg.js строит граф потока управления, следуя по всем путям выполнения от PC=0, декодируя каждую инструкцию с правильным контекстом шифрования.
PX использует перекрывающиеся инструкции на границах блоков. Одни и те же байты декодируются как операнды на одном пути выполнения и как опкоды на другом, в зависимости от контекста шифрования блока. Линейное сканирование декодирует каждую позицию байта один раз и упускает альтернативный путь. CFG следует как за сквозными, так и за прыжковыми рёбрами, декодируя каждый путь независимо.
_0x3ca8): Статический murmur XOR над сырыми байтами base64, ключ 4008000571_0xece1): Пофункциональный XOR с двумя подуровнями: статическим ключом, зависящим от позиции, и ключом хэша целостности кода_0x427d): Роллинг XOR для каждого блока. Каждый блок шифрования (определяется границами fn.bl) получает дополнительный XOR, производный от ключа функции и индекса блока. Блок 0 не зашифрован при первом доступе; блоки 1+ зашифрованы. Вот почему линейный дизассемблер работает для первого блока, но выдаёт мусор для последующих.CFG применяет все пять уровней неразрушающим образом (операндный XOR вычисляется на лету, не на месте), чтобы перекрывающиеся области инструкций не портили друг друга.
Для каждого лидера суперинструкции CFG читает подбайт, находит фактический обработчик в super_groups.json и декодирует операнд для реального опкода. Цели прыжков из слитых прыжковых опкодов (например, то, что выглядит как ASSIGN_OP_VAR, но на самом деле JMP) отслеживаются корректно.
node cfg.js # все функции -> cfg_output/
node cfg.js 79 # одна функция в stdout
Проверено по трассировкам выполнения в браузере: 600 инструкций, отслеженных в 14 функциях, 0 несовпадений по дельтам стека. Все 34 уникальных опкода валидированы. Диспетчеризация суперинструкций подтверждена корректной для всех 10 групп лидеров, которые выполнялись во время инициализации.
Принимает вывод CFG и запускает эмуляцию стека для создания комментариев с выражениями. Преобразует сырой байткод в читаемый псевдокод.
node cleaner.js 79 # fn79 в stdout
Проходит по инструкциям по порядку, отслеживая виртуальный стек. Каждое push/pop/call строит строку выражения:
0018 GET_VAR ; 0.0001
001e GET_VAR ; or
0024 PUSH_CONST ; "_0x166"
002b CALL_METHOD_C ; 0.0001._0x88(or, "_0x166")
...
0114 PUSH_CONST ; "fontSize"
011b PUSH_CONST ; "pdc"
0122 CALL_METHOD_C ; _0x18c.getHours("fontSize", "pdc")
Подавляется только чистый арифметический/сравнительный шум на пустом стеке. Всё остальное сохраняется, поскольку CFG уже отфильтровал медовые и заполняющие инструкции.
112 функций, 5435 инструкций сохранено, 207 шумовых подавлено. fn79 (сборщик отпечатков, 1109 инструкций) имеет 85% покрытия комментариями выражений.
Стековая VM с 256-слотовым стеком, цепочкой областей видимости, цепочкой обработчиков try/catch и стеком итераторов for-in. Цикл диспетчеризации читает 2-байтовые LE опкоды, разрешает через перестановку + позиционный XOR + смещение блока, дешифрует операнды на месте, выполняет обработчик, затем повторно шифрует операнды, так что байткод никогда не находится в полностью расшифрованном виде в памяти.
BigInt, modPow, экспонента 65537getTotalLength() и getBBox() на сконструированных путяхperformance.timingCC|CD-04|BREAKPOINT-005)ИИ использовался для помощи в документировании кода, написании инструментов и составлении этого readme.
Исключительно в образовательных целях / для исследований в области безопасности. Никаких решателей или обходов, только документирование того, как работает VM, потому что это действительно интересно.
Если у кого-то из PerimeterX/HUMAN Security есть вопросы по этому репозиторию, не стесняйтесь обращаться: [email protected]