
PerimeterXのバイトコードVM用リバースエンジニアリングツールキット。CFGベースの逆アセンブラ、5層の復号パイプライン、オペコードテーブルの再構築、ボット検出フィンガープリンティングのセキュリティ研究のためのスタックエミュレーションクリーナーを備えています。
このリポジトリは、PerimeterX の auditor.js のリバースエンジニアリングを文書化したものです。auditor.js は、PX のボット検出パイプラインにおいて二次的なフィンガープリンティング層として使用されるバイトコード仮想マシンです。この分析では以下をカバーしています。
注: このリポジトリは1つの(静的)VMバージョンのみを対象としており、セキュリティ研究および解析目的を意図しています。動的ソルバーや本番用ソルバーの実装は含まれていません。
2026年4月2日木曜日、PerimeterX はボット検出パイプラインの一部として新たなバイトコードVMを展開しました。
auditor.js は通常の PX センサースクリプトのように見えません。通常の難読化されたプロパティルックアップやコレクター関数の代わりに:
_fg0 から _fg7)、暗号化された VM プログラムが変数に分割されています_dp)はサイト固有の鍵(_pk)を使用し、プログラム JSON を展開します_0x8df7)は VM 自身のソースをハッシュ化して復号鍵を導出するため、変更を加えるとバイトコードの復号が静かに失敗しますVM プログラムは 8 つの変数に分割され、連結された後、_dp() によって _pk を鍵とするサイト固有の XOR 暗号を用いて復号されます:
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 オブジェクトであり、キー名は難読化された2文字(例:シードは "uo"、ナンスは "dk")です。変換テーブルによって標準名に変換されます。
node extractor.js
# -> program.json
| フィールド | 説明 |
|---|---|
s | シード (12755)、すべての暗号操作を駆動 |
n | ナンス (1603730985)、プログラムごとのランダム化 |
g | ジェネレータフラグ、整合性ハッシュ復号層を有効化 |
x | 暗号化フラグ、定数は XOR 暗号化されている |
c | 定数プール、1230エントリ |
f | 関数、112エントリ(暗号化されたバイトコードを含む) |
e | エントリポイント、関数インデックス 0 |
1095 個すべての文字列定数は 2 層で暗号化されています。
層 1: 静的 murmur XOR、鍵は 4008000571、位置依存。
層 2: PRNG ストリーム XOR、glibc LCG を使用。シードはプログラムシードと各定数のインデックスを Knuth の乗算ハッシュで組み合わせて生成。
復号前に、シードは 環境フィンガープリント(_0xaf48)と XOR されます。これはブラウザ API をプローブして計算される 8 ビットのビットマスクです:
| ビット | テスト | 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、よって有効シード = 12755 ^ 127 = 12716。
これは、同じプログラムでも異なる環境で異なる復号結果が得られることを意味します。Node.js、Chrome、Firefox で実行すると異なるシードになります。
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 (base64 で 65537), 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
JavaScript の全言語をカバーする 107 個の基本オペコードに加え、動的に生成されるノイズ:
40 個のハニーオペコード は、算術/比較演算の代替実装であり、数学的に等価だが構文的に異なる式を使用します。ADD は (a^b) + 2*(a&b) や -((-a)-b) や a-(-b) として現れることがあります。各基本オペコードは最大 3 つのバリアントを持ち、シードから決定論的に生成されます。単純な ADD 命令が同じプログラム内で 4 つの異なるバイトコード値として現れ、パターンマッチング手法を破ります。
24 個のパディングオペコード は並び替えに割り当てられますが、ハンドラはなく、決して発行されません。これらはオペコード空間を拡大し、シャッフルを逆転しにくくするために存在します。
16 個のスーパーインストラクショングループ は最も重要なアンチ解析機能です。ディスパッチループがオペコードをスーパーインストラクションリーダーとして解決すると、ハンドラはバイトコードストリームから追加で 1 バイトを読み取り、サブハンドラにディスパッチします。サブハンドラはまったく異なる操作になる可能性があります:
| リーダーとして解決 | サブバイト | 実際に実行 |
|---|---|---|
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 |
オペコードテーブルは有効シードをシードとした Fisher-Yates でシャッフルされるため、バイトコードの値はビルドごとに異なります。
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
このツールキットの中核です。cfg.js は、PC=0 からすべての実行経路を追跡し、各命令を正しい暗号化コンテキストでデコードすることで制御フローグラフを構築します。
PX はブロック境界で重複する命令を使用します。同じバイトが、一方の実行経路ではオペランドとして、もう一方の実行経路ではオペコードとしてデコードされます。これはブロック暗号化コンテキストに依存します。線形スキャンは各バイト位置を一度だけデコードし、代替経路を見逃します。CFG はフォールスルーエッジとジャンプエッジの両方を追跡し、各経路を独立してデコードします。
_0x3ca8): 生の base64 バイトに対する静的 murmur XOR、鍵は 4008000571_0xece1): 関数ごとの XOR、2 つのサブレイヤー:位置依存の静的鍵 + コード整合性ハッシュ鍵_0x427d): ブロックごとのローリング XOR。各暗号化ブロック(fn.bl 境界で定義)は、関数鍵とブロックインデックスから導出される追加の XOR を受け取ります。ブロック 0 は最初のアクセス時は暗号化されていません。ブロック 1 以降は暗号化されています。これが、線形逆アセンブラが最初のブロックでは機能するが、後続のブロックではゴミを生成する理由です。CFG は 5 層すべてを非破壊的に適用します(オペランド XOR はその場で計算され、インプレースではない)。これにより、重複する命令領域が互いに破損しません。
各スーパーインストラクションリーダーについて、CFG はサブバイトを読み取り、super_groups.json で実際のハンドラを検索し、実際のオペコードのオペランドをデコードします。融合されたジャンプオペコード(例えば、ASSIGN_OP_VAR のように見えて実際は JMP であるもの)からのジャンプ先も正しく追跡されます。
node cfg.js # 全関数 -> cfg_output/
node cfg.js 79 # 単一関数を標準出力
ブラウザの実行トレースで検証済み:14 関数にわたって 600 命令をトレース、スタックデルタの不一致 0。34 のユニークなオペコードすべてを検証。初期化中に実行された 10 のリーダーグループすべてで、スーパーインストラクションディスパッチが正しいことを確認。
CFG の出力を受け取り、スタックエミュレーションを実行して式コメントを生成します。生のバイトコードを読みやすい疑似コードに変換します。
node cleaner.js 79 # fn79 を標準出力
命令を順に辿り、仮想スタックを追跡します。各 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 バイトのリトルエンディアンオペコードを読み取り、順列 + 位置 XOR + ブロックオフセットで解決し、オペランドをインプレースで復号、ハンドラを実行し、その後オペランドを再暗号化するため、バイトコードがメモリ上で完全に復号されることはありません。
BigInt、modPow、指数 65537 を用いたフィンガープリント出力の RSA 暗号化getTotalLength() および getBBox() による SVG レンダリングフィンガープリンティングperformance.timing ウォーターフォール収集CC|CD-04|BREAKPOINT-005)コードの文書化、ツールの作成、この README の草稿には AI が使用されています。
純粋に教育/セキュリティ研究目的です。ソルバーや回避策は含まず、VM の動作を文書化しているだけです。それは純粋に興味深いからです。
PerimeterX/HUMAN Security の関係者でこのリポジトリに関して懸念がある方は、遠慮なくご連絡ください:[email protected]