
新しいDatadome VMのリバースエンジニアリング 🔥
このリポジトリは、DataDome の CAPTCHA/インタスティシャルフローで使用されるブラウザ内 JavaScript 仮想マシン (VM) の初の公開版を文書化したものです。この解析では以下を扱います:
注記: このリポジトリは、静的 VM の1つのバージョンのみを対象としており、セキュリティ研究および解析目的で作成されています。動的ソルバーやプロダクション用ソルバーの実装は含まれていません。
2026年1月14日、DataDome はクライアントタグに新しい VM ベースのコンポーネントを導入し始めました。
VM コードは CAPTCHA チャレンジから抽出され、vm.js(このリポジトリ内にあり)に保存されています。
最初のステップはスクリプトの難読化解除でした:
難読化は単純で、各変数を評価し、実際の値で置き換えるだけです。難読化解除スクリプトは deobf.js にあります。
難読化解除されたコード(out.js)を DevTools で実行すると、VM の期待される出力が表示されます:
出力は2つの数値と1つの文字列を含む JSON オブジェクトです。それでは実際の VM 実装に深く潜り込んでみましょう。
Q.exports 関数の先頭で、バイトコードがどのようにデコードされるかがわかります:
B() を使用。疑似乱数生成器)
-> D はデコードされたバイトコードを保持し、ランダムな「ノイズ」が含まれている
スクロールダウンすると、VM のエントリポイントが現れます:2つのパラメータ A(バイトコード)と Q(エラーハンドリング用の空の辞書)を持つ関数です。
この VM の最も興味深い点はそのアーキテクチャです:すべてが単一の配列(A)に存在します。この配列には以下が格納されています:
この設計は、実際のコンピュータアーキテクチャの異なるメモリ領域を反映しています。次のステップは各オフセットをマッピングし、どこに何が格納されているかを理解することです:
var stack_pointer = 4593
var instruction_pointer = 4635
var frame_base_pointer = 4674
var last_result = 4633
var exit_flag = 4656
var current_opcode_handler = 4685
var current_opcode_id = 4675
var stack_offset = 124482
var vm_start = 5258
これらのオフセットがマッピングされると、VM の構造が明確になります。
VM は一連のヘルパー関数から始まります。これらの関数は以下を扱います:
ヘルパー関数の後、VM はコア値を初期化します:
初期化の下にはすべての命令ハンドラがあります。
ディスパッチャは、`exit_flag` がセットされるまで実行されるメイン VM ループです:
I は現在の命令を表しますP は配列への実際のオフセット(難読化を考慮)current_opcode_handler に設定し、current_opcode_id を更新します
以下はオペコードハンドラの基本的な例です:
%= または ^=)fetch() 関数を呼び出す最も複雑なオペコードの1つで、クロージャ/関数を作成します:
A[4919] = function () {
var Q = readUint8(); // 期待される引数の数
var B = [];
for (var E = readUint8(), D = 0; D < E; D++) {
var g = readUint8();
var a = A[A[frame_base_pointer] + g];
B.push(a); // 現在のスコープから変数をキャプチャ
}
var h = A[instruction_pointer] + 3; // 関数本体のアドレスを保存
A[A[stack_pointer]++] = function (E) {
// 呼び出し時に新しいスタックフレームを設定
var e = A[stack_pointer] - E;
while (E < Q) {
A[e + E++] = undefined; // 不足している引数を undefined で埋める
}
A[stack_pointer] = e + Q;
for (var D = 0; D < B.length; D++) {
var g = B[D];
A[A[stack_pointer]++] = g; // キャプチャした変数をプッシュ
}
A[e - 2] = A[frame_base_pointer]; // 古いフレームポインタを保存
A[e - 1] = A[instruction_pointer]; // 戻りアドレスを保存
A[frame_base_pointer] = e;
A[instruction_pointer] = h; // 関数本体にジャンプ
};
fetch();
};
このオペコードは:
undefined で埋めるこのオペコードは、通常の関数呼び出しとコンストラクタ呼び出しの両方を処理するラッパーを作成します:
A[5003] = function () {
var Q = A[--A[stack_pointer]]; // POP 関数
var B = A[--A[stack_pointer]]; // POP 'this' コンテキスト
function E(e) { // e = 引数の数
var D = A[stack_pointer];
var g = A.slice(D - e, D); // スタックから引数を取得
if (this instanceof E) {
// コンストラクタ呼び出し (new E(...))
g.unshift(null);
var h = Function.prototype.bind.apply(Q, g);
A[stack_pointer] -= e;
try {
a = new h();
} catch (A) {
a = A.message;
}
A[A[stack_pointer]++] = a;
} else {
// 通常の関数呼び出し
var t;
try {
t = Q.apply(B, g);
} catch (A) {
t = A.message;
}
A[stack_pointer] -= e + 2;
A[A[stack_pointer]++] = t;
}
}
A[A[stack_pointer]++] = E;
fetch();
};
このオペコードは:
new)か通常の呼び出しかを検出A[4961] = function () {
var Q = {};
for (var E = readUint16(), e = 0; e < E; e++) {
var D = A[--A[stack_pointer]]; // 最初の POP
var g = A[--A[stack_pointer]]; // 2番目の POP
Q[D] = g;
}
A[A[stack_pointer]++] = Q;
fetch();
};
このオペコードはオブジェクトリテラルを構築します:
object[firstPop] = secondPop各命令は fetch() を呼び出して終了し、これが次の命令を準備します:
function fetch() {
var Q = A[instruction_pointer];
var B = A[vm_start + Q];
A[instruction_pointer] = Q + 1;
var E = A[4783 + B];
A[current_opcode_handler] = E;
A[current_opcode_id] = B;
}
この関数は:
current_opcode_handler と current_opcode_id を更新これはディスパッチャのロジックを反映し、VM アーキテクチャに典型的な fetch-decode-execute サイクルを作り出します。
解析を支援するために、VM のバイトコードを人間が読めるアセンブリに変換する概念実証の逆アセンブラ(disasm.js)が開発されました。
逆アセンブラは2パスで動作します:
最初のパスではバイトコードをスキャンし、すべてのジャンプ先を特定します。これには以下が含まれます:
JMP_FWD、JMP_BACK)JZ、JNZ_KEEP、JZ_KEEP)各ターゲットアドレスにはラベル(例:L_0042)が付けられ、制御フローを追跡しやすくします。
2番目のパスでは、各命令をアセンブリ風の出力に変換します:
000042: fa 00 0a PUSH_IMM 10
000045: 19 00 19 PUSH_REG 25
000048: eb ADD
各行には以下が含まれます:
より複雑な側面の1つは、バイトコードに埋め込まれた即値のデコードです。VM は型マーカーを使用して、後続のバイトをどのように解釈するかを示します:
単純型(追加データなし):
0x28 → true0x7D → false0x4C → null0x3D → undefined小整数(0-127):上位ビットがセットされてエンコード
0x85 → 5 (0x80 | 5)文字列: XOR エンコードされ、ヌル終端
0x67、XOR キーは183から開始0x27、XOR キーは46から開始数値型:
0x6F + 1バイト0x61 + 2バイト(ビッグエンディアン)0x65 + 3バイト(ビッグエンディアン)0x54 + 4バイト(ビッグエンディアン)0x05 + 8バイト文字列の XOR エンコードは単純ですが、カジュアルな検査を防ぎます:
let str = '';
let xorKey = 183; // ASCII 文字列の初期キー
let ch;
while ((ch = readByte() ^ (xorKey++ & 0xFF)) !== 0) {
str += String.fromCharCode(ch);
}
一部のオペコードはカスタム処理を必要とします:
CLOSURE(オペコード 136): キャプチャ変数を持つ関数/クロージャを作成
形式: CLOSURE locals, capture_count, [capture_indices...], skip_offset
スキップオフセットは関数本体の後を指し、線形実行中に VM が関数定義をスキップできるようにします。
PUSH_MULTI_IMM(オペコード 96): 一度に複数の値をプッシュ
形式: PUSH_MULTI_IMM count, val1, val2, ...
# ファイルから逆アセンブル
node disasm.js bytecode.txt
; DataDome VM Disassembly
; Bytecode size: 5428 bytes
; VM Constants: VM_START=5258, OPCODE_BASE=4783
000000: 88 01 00 04 CLOSURE locals=1, captures=[0, 4], body=L_0006, end=L_0a3f
L_0006:
000006: fa 67 ... PUSH_IMM "window"
00001f: 2b PUSH_WINDOW
000020: 02 SET
000021: fa 67 ... PUSH_IMM "navigator"
00003a: 19 00 00 PUSH_REG 0
00003d: fa 67 ... PUSH_IMM "navigator"
000056: ee GET
000057: 02 SET
...
この出力形式により、以下が可能になります:
私はまだ VM に関して初心者なので、すべてを割り引いて受け取ってください。コードの文書化とこの Readme の一部の作成には AI を使用しています(文書作成は面倒なので)。
これは純粋に教育/セキュリティ研究目的です。ソルバーやバイパスは含まれていません。単に VM がどのように動作するかを文書化しているだけであり、それが本当に興味深いからです。
DataDome 様: これを読んでいたら、こんにちは!!!これは単に奨学金を得るための試みです 🙏。どうか私を訴えないでください。文字通り無一文です。このリポジトリに関して何か問題があれば、ご連絡ください。話し合いましょう :)
ごめんなさい、VM 内部の話をすると思った方々…それはありません。
DataDome API を求めて DM しないでください。お手伝いできません。