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