docs(znca): whitebox/CFF/anti-debug/self-kill/gen_audio_h 深堀と旧結論の訂正

`+[VoIPClient initGenAudioH]` と `_gen_audio_h` の内部構造を並列 5 系統
で追加解析。旧 `znca_ios_init_gen_audio_h_static.md` の主要結論が複数覆さ
れたので、脚注ベースで訂正しつつ 6 本の新規 spec ドキュメントに事実関係を
移した。

## 訂正された旧結論

- `0x101034204` は CFF dispatch 変数ではなく write-only decoy sink
  (629 writes / 0 reads)。真の CFF state は 693 個の private stack slot、
  9 zone-base regs (x8/x12/x13/x14/x15/x19/x20/x22/x25/x26/x28) 経由
- 「CFF 694 dispatch site」のうち functional edge はわずか 15、残り 678 は
  opaque predicate による no-op (99.7% がダミー)
- 生 SVC 分類: sysctl×12 → sysctl×10 + getpid×4
  (`mov w8,#0x14 → mov w16,w8` の register-forward で拾えていなかった)
- `kill()` × 3 は全て sig=0 の良性 liveness probe、self-terminate では
  なかった (SVC 直前の `str wzr,[slot]` + dynamic emulation で確認)
- `sysctl` の MIB は 9/10 で `[CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1,
  pid]` に確定 (`kinfo_proc` P_TRACED パターン)
- 旧 doc の brk 2 発 (`0x1007d98b0`, `0x1007d9a68`) は initGenAudioH に属
  さず、無関係な `0x1007d94d0`–`0x1007d9bd0` に属する
- 「Frida hook 時の 500 回再帰 objc_msgSend」は静的に不可能と判明
  (`objc_msgSend$initGenAudioH` stub は caller が 1 箇所のみ、selref も 1
  箇所のみ)。旧 doc の crash 記述の `Crew@0x7D5B4` は `Crew@0x7D5F4`
  (initGenAudioH コールサイトのリターン IP) の転記ミスの可能性大
- whitebox blob (2.13 MiB @ 0x100f70000..0x101192000) の内訳:
  1.76 MiB (82.6%) pure table + 380 KiB (17.4%) CFF plumbing (31 islands,
  2775 chained-fixup pointer)
- Chow-style AES 仮説は refute: 生 S-box (`63 7c 77 7b`) は blob 全域全
  alignment で 0 hit、`_gen_audio_h` の register-indexed LDRB も 0
  (SIMD `ldr q0` が 8 発のみ)
- `_gen_audio_h` の 8 個 SIMD load は 6/8 が自関数の 283,520 バイトのスタッ
  クフレーム (`x19`) を読んでおり、whitebox blob を直接 index していない。
  9-subtable 仮説の SIMD 経路は refute
- `_set_platform_for_gen_audio` は x0 を読まず self-contained、可変
  platform id は渡していない
- `_gen_audio_h` は CryptoPP SHA-256 (K[64]) を直接呼ばず、`_CC_SHA256`
  stub も未使用

## 新規 spec ドキュメント

- `docs/spec/znca_ios_whitebox_layout.md`: blob の pure/plumbing split、
  entropy/pointer-fixup 解析
- `docs/spec/znca_ios_init_gen_audio_h_cff.md`: CFF de-flatten 結果
  (dispatch idiom `target = base + state`、functional edge 15/694)
- `docs/spec/znca_ios_antidebug_strings.md`: SVC 35 全分類、MIB/signal
  確定、runtime string decoder は 425M+ emulate でも未到達
- `docs/spec/znca_ios_true_selfkill.md`: 空振り報告 + 旧 crash 観測の
  転記ミス仮説
- `docs/spec/znca_ios_gen_audio_h_internals.md`: 8 SIMD load 追跡、
  9-subtable 仮説の refute
- `docs/spec/znca_ios_init_gen_audio_h_static.md`: 上記に沿ってピンポ
  イント訂正パッチ (400→441 行、Edit のみ)

## 未解決 (追跡不可で正直に残置)

- 難読化 anti-debug 比較文字列の runtime decoder 位置と平文値
- 真の self-kill 経路 (存在するかも含めて要実機再現)
- 2 個の pointer-indirect SIMD base の pointee provenance
- `_gen_audio_h` の 277 KiB stack scratch zone の populate 経路
- 間接 crypto call path があるか

## 補助生成物

- `targets/znca_ios_3.4.1/whitebox/tools/*.py`: 前セッションで消失した
  npy 生成コードを LIEF ベースで復元
- `targets/znca_ios_3.4.1/analysis/{cff,antidebug,selfkill,gen_audio_h}/
  *.py`: capstone/lief/emulator ベースの解析スクリプト群
- CFG dot ファイル: `analysis/cff/cfg_full.dot` + 11 zone 別
- 中間 npy/output: `whitebox/analysis/*.npy`,
  `analysis/gen_audio_h/*_output.txt`

## 除外

Nintendo バイナリ (Crew / GameWidgetsExtension / NotificationService /
whitebox_data_*.bin) と Frida ランタイムログはコミット対象外。pickle
キャッシュ (`insns.pkl`, `cfg.pkl`) はスクリプトから再生成可能なので除外。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
tkgstrator
2026-07-03 10:53:06 +00:00
co-authored by Claude Opus 4.7
parent 703a2afef1
commit 3b28789bff
82 changed files with 117086 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
# znca iOS 3.4.1 — `+[VoIPClient initGenAudioH]` 本体のアンチデバッグ文字列/定数 解析
対象: `/home/vscode/app/targets/znca_ios_3.4.1/Crew` (Mach-O arm64, `__TEXT` vmaddr = `0x100000000`)
対象範囲: `initGenAudioH` 本体 `0x100783fc8` .. `0x1007d594c` (335 KiB, OLLVM control-flow-flattening 済み単一関数)。
前提となる静的解析結果は [`znca_ios_init_gen_audio_h_static.md`](./znca_ios_init_gen_audio_h_static.md) を参照。本ドキュメントはその §7「未確定」項目を追跡調査した結果。
**方針**: CFF (control-flow flattening) により線形の逆方向レジスタスライスは機能しないため、(a) `movz`/`mov` 直前文脈の静的スキャン(確定的に読める部分のみ)と、(b) Unicorn Engine による実行時エミュレーション(rebase/bind 解決済みフラットイメージ上で `initGenAudioH` 本体を実際に動かし、import stub と raw syscall をフックしてログを取る)の 2 経路を併用した。**CFF 内で追跡しきれなかった項目は推測で埋めず「未確定」と明記する。**
---
## 0. 手法とツール
| スクリプト | パス | 役割 |
|---|---|---|
| `common.py` | `targets/znca_ios_3.4.1/analysis/antidebug/common.py` | lief/capstone ヘルパ (segment 解決, disasm, entropy) |
| `scan_svc_context.py` | `targets/znca_ios_3.4.1/analysis/antidebug/scan_svc_context.py` | 全 35 `svc #0x80` を検出し、直前 14 命令ウィンドウで `x16`/`w16` セット元を特定 (静的) |
| `dump_loop_region.py` | `targets/znca_ios_3.4.1/analysis/antidebug/dump_loop_region.py` | 特定アドレス範囲の逆アセンブルダンプ (汎用) |
| `build_image.py` | `targets/znca_ios_3.4.1/analysis/antidebug/build_image.py` | dyld chained-fixups の rebase/bind を全解決したフラットメモリイメージを構築 |
| `emulate.py` | `targets/znca_ios_3.4.1/analysis/antidebug/emulate.py` | Unicorn Engine で `initGenAudioH` 本体を実行し、import stub 37 種 + raw syscall 10 種 + 未モデル化 external bind (3331 シンボル) をフックしてログを取る |
エミュレーション実行ログ: `/tmp/emu_log.txt` (実行毎に上書き)。過去ラン: `/tmp/emu_run3.txt` (INTERNAL_SKIP 導入後、strlen 系クラッシュ発見前)、`/tmp/emu_run5.txt` (strlen 系クラッシュ修正後、最終到達点)。
### エミュレーション環境の限界(正直な申告)
- x0=self / x1=_cmd はダミー値。呼び出し元 (`+[VoIPClient genAudioH:...]` 等) から渡される実引数は再現していない。
- `pthread_create` はスレッドを実際には起動しない (no-op で成功を返すのみ)。バックグラウンドスレッドが立てるはずのフラグは永遠に立たない。
- `sysctl`/`open`/`access` 等のファイルシステム系 syscall は「クリーンな非 jailbreak 端末」を装う固定応答 (ENOENT 等) を返すのみ。
- 未知の bind シンボル (libc++ 内部関数など 3331 種) は「呼ばれたことをログして 0 を返す」汎用フォールバックで処理しており、実装を再現していない。
- この結果、**あるチェックループの内部で 425M 命令 (実測で 600 秒タイムアウト予算の大半) を消費しても終了しない長大なループに到達し、そこから先 (`getenv`/`dlopen`/`dlsym`/`opendir` の実引数を含む) には到達できなかった**。詳細は §5。
---
## 1. 発見テーブル (呼び出し位置 / API / 引数種類 / 難読化blob位置 / decoder位置 / 平文候補 / 確度)
| 呼び出し位置(VA) | 呼び出しAPI | 引数種類 | 難読化blob位置 | decoder位置 | 平文候補 | 確度 |
|---|---|---|---|---|---|---|
| `0x10079eb20` (svc) | `symlink(path1, path2)` (raw syscall #0x39) | path1: 24byte バイナリ列 (非ASCII), path2: 空文字列 | スタックフレーム内 (`x19` 相対、CFF で動的に選択される複数オフセット。エミュレーションでは `path1` が `88 28 d2 e2 ee ea db 98 8e e6 fc 4a 85 b8 cf 69 34 d9 e2 a0 11 85 3a 9b` として観測) | 未特定。上記バイト列に既知の固定 XOR/rotate をかけても printable にならず、その場でエンコードされた乱数 (uuid 由来のシンボリックリンク名) である可能性が高い | 未確定 (デコード不能。固定リテラルではなく実行時ランダム値の可能性が高いという所見のみ) |
| `0x1007ae4e0` (svc) | `symlink(path1, path2)` (raw syscall #0x39) | 同上 | 同上 (別スタックオフセット) | 未到達 (エミュレーションで未実行) | 未確定 |
| `0x1007c3c5c` 付近 | `getenv(name)` | name: C 文字列ポインタ | CFF 内、実行時にエミュレーションが到達不可 | 未特定 | **未確定** (今回のエミュレーションでも到達できず。ドキュメント既存の「`DYLD_INSERT_LIBRARIES` 推定」は根拠のない推測であり本調査では追認も否定もできない) |
| `0x1007a4b98` 付近 | `dlopen(path, mode)` | path: C 文字列ポインタ | 同上 | 未特定 | 未確定 |
| `0x1007c3f30` 付近 | `dlsym(handle, symbol)` | handle: `RTLD_NEXT` (`movn x0,#1` を静的に確認済み), symbol: C 文字列ポインタ | 同上 | 未特定 | handle=`RTLD_NEXT` の部分のみ**確定** (静的に `movn x0, #1` を確認済み)。symbol 文字列は未確定 |
| `0x1007c199c` | `dladdr(addr, info)` | addr: 実行時アドレス | — | — | 呼び出し自体は確認済み (確定)。用途 (dylib 列挙時の相互チェック) は推定 |
| `0x1007c8bf4`/`0x1007c8bf8` | `_dyld_image_count()` | 引数なし | — | — | 呼び出し自体を動的に確認 (確定)。ret=1 (エミュレーション環境固有: image が 1 個しか無いため) |
| `0x1007c89c0`/`0x1007c89c4` | `_dyld_get_image_name(idx)` | idx: int | — | — | 呼び出し自体を動的に確認 (確定)。比較先の文字列 (`frida`/`substrate` 等) は未到達につき未確定 |
| `0x10079d960` | `CFCopyHomeDirectoryURL()` | 引数なし | — | — | 確定 (動的確認)。ホームディレクトリ URL 取得 |
| `0x1007a19cc` | `CFURLCopyFileSystemPath(url, style)` | — | — | — | 確定 (動的確認) |
| `0x1007a19e8` | `CFStringGetCString(str, buf, bufsize, encoding)` | buf: スタックバッファ (`x19` 相対) | — | — | 確定 (動的確認)。ホームパスをバッファにコピーする処理と判明。`bufsize` 引数は `0x400`。この一連の呼び出し (CFCopyHomeDirectoryURL→CFURLCopyFileSystemPath→CFStringGetCString→CFRelease×2→`__snprintf_chk`) は「ホームディレクトリ相対のパス文字列を構築している」ことは確定だが、構築される具体的なパス (`~/Library/...` 等) は `__snprintf_chk` のフォーマット文字列引数が CFF に埋もれておりエミュレーションでも捕捉できず未確定 |
| `0x1007c9b14` | `kill(pid, sig)` (raw syscall #0x25) | pid: 未追跡レジスタ由来 (下記表参照), **sig: 直前命令 `str wzr, [slot]` により静的に 0 と確定** | — | — | **sig=0 は確定** (静的+動的両方で一致)。pid の値は未確定 (下記参照) |
| `0x1007cc560` | `kill(pid, sig)` (raw syscall #0x25) | 同上 | — | — | 同上、sig=0 確定 |
| `0x1007cdd44` | `kill(pid, sig)` (raw syscall #0x25) | 同上 | — | — | 同上、sig=0 確定 |
| `0x1007ca000`, `0x1007ca4fc`, `0x1007ca6ec`, `0x1007cca8c`, `0x1007ccd3c`, `0x1007cd17c`, `0x1007cdfc0`, `0x1007ce200`, `0x1007ce8d8` (9/10) | `__sysctl(mib, miblen, oldp, oldlenp, newp, newlen)` (raw syscall #0xca) | mib: **`[1, 14, 1, 0]` を動的に確認 (9/10 サイト)** | mib 配列はスタック上 (`x19` 相対、CFF で複数レジスタ経由) | — | **MIB 構造 `{CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1, pid}` は確定** (`kinfo_proc`/`P_TRACED` 型アンチデバッグの典型形。9/10 サイトで完全一致)。ただし **4 番目のワード (pid) が全サイトで `0` として観測されており、これは実機の `getpid()` 実値ではなくエミュレーション上のアーティファクトである可能性が高い**(後述 §4) |
| `0x1007c499c` (10/10 中未到達) | `__sysctl(...)` (raw syscall #0xca) | 同上 (推定) | 同上 | — | 未確定 (今回のエミュレーションでは到達せず、MIB を実測できなかった) |
| `0x1007c4824`, `0x1007c9a74`, `0x1007ccf64`, `0x1007cdce4` | `getpid()` (raw syscall #0x14=20) | 引数なし | — | — | **確定 (静的)**。旧ドキュメントで「movz 検出できず」としていた 4 箇所は、直前の `w16 = w8/w9` (レジスタ間接) を辿ると全て `mov w8, #0x14` (=20=getpid) に帰着することを確認した。すなわちこの 4 箇所は sysctl でも謎の syscall でもなく **raw `getpid()`** |
| `0x7fb000002250` (fakebind cell, 元は `_strlen` の lazy-bind GOT スロット) | `strlen` | ポインタ引数 x0 (エミュレーション観測値 `0x100dad681`, `__TEXT` 領域内の定数アドレス) | — | — | エミュレーション基盤側の課題として発見 (アンチデバッグ本体とは無関係)。`STUBS` テーブルに未収録の libc 関数呼び出しが `__FAKEBIND` の未初期化データセルへ直接分岐してクラッシュしていた。汎用フォールバックフックで解消済み (詳細 §6) |
---
## 2. `sysctl` 全 10 箇所の `movz`/`mov` コンテキストスキャン (静的、確定)
`scan_svc_context.py` の出力から、`svc #0x80` 直前 14 命令内での x16 設定元を全数確認した(想定と異なり **12 ではなく 10 箇所**だった。旧ドキュメントの「12」は誤りで、残る 4 箇所は後述の通り `getpid` である)。
| # | SVC VA | x16 セット元命令 | 値 | miblen セット (`w1`) | MIB (動的観測。9/10) |
|---|---|---|---|---|---|
| 1 | `0x1007c499c` | `0x1007c4998: mov x16, #0xca` | `0xca` (202=`__sysctl`) | `mov w1, w9` (レジスタ由来、未追跡) | 未観測 (到達せず) |
| 2 | `0x1007ca000` | `0x1007c9ffc: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 3 | `0x1007ca4fc` | `0x1007ca4f8: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 4 | `0x1007ca6ec` | `0x1007ca6e8: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 5 | `0x1007cca8c` | `0x1007cca88: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 6 | `0x1007ccd3c` | `0x1007ccd38: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 7 | `0x1007cd17c` | `0x1007cd178: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 8 | `0x1007cdfc0` | `0x1007cdfbc: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 9 | `0x1007ce200` | `0x1007ce1fc: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
| 10 | `0x1007ce8d8` | `0x1007ce8d4: mov x16, #0xca` | `0xca` | 同上 | `[1, 14, 1, 0]`, miblen=4 |
各サイトの直前 6 命令パターン (10 箇所とも同一構造、代表として `0x1007c499c`):
```
ldr x8, [x28, #0xca0] ; mib pointer (frame-relative, レジスタは箇所ごとに異なる)
ldr w9, [x28, #0xc9c] ; miblen
ldr x10, [x28, #0xc90] ; oldp
ldr x11, [x28, #0xc88] ; oldlenp
ldr x12, [x28, #0xc80] ; newp
ldr x13, [x28, #0xc78] ; newlen
mov x0, x8
mov w1, w9
mov x2, x10
mov x3, x11
mov x4, x12
mov x5, x13
mov x16, #0xca
svc #0x80
```
**確定事項**: 10 箇所全てが同一の 6 引数ロードパターンを持ち、`mib`/`oldp`/`oldlenp` 等はすべて frame-relative メモリ (CFF によりベースレジスタが箇所ごとに異なる) からロードされる。9 箇所で動的に MIB 実値 `[1, 14, 1, 0]` (`CTL_KERN, KERN_PROC, KERN_PROC_PID, pid=0`) を確認した。
---
## 3. `kill(pid, sig)` 3 箇所の `w1` (signal) トレース (静的+動的、確定)
3 箇所とも直前のコードで **signal スロットが `str wzr, [slot]` で明示的にゼロクリアされてから w1 にロードされている** ことを確認した。これは推測ではなく逆アセンブル上で直接読める事実。
| SVC VA | pid スロット設定 | sig スロット設定 | 動的観測 |
|---|---|---|---|
| `0x1007c9b14` | `str w11, [x25, #0x760]` (w11 は CFF 上流で未追跡) | **`str wzr, [x25, #0x75c]`** ← 明示的ゼロ | `kill(pid=0x0, sig=0)` |
| `0x1007cc560` | `str w11, [x13, #0xf48]` (同上) | **`str wzr, [x13, #0xf44]`** ← 明示的ゼロ | `kill(pid=0x0, sig=0)` |
| `0x1007cdd44` | `str w8, [x22, #0x218]` (同上) | **`str wzr, [x22, #0x214]`** ← 明示的ゼロ | `kill(pid=0x0, sig=0)` |
**結論 (旧ドキュメントの訂正)**: 旧 `znca_ios_init_gen_audio_h_static.md` §4-2 は「`kill(getpid(), SIGKILL)` あるいは `SIGABRT` と推定」と記していたが、これは誤りである可能性が高い。**この 3 箇所の raw `kill` は signal=0 で固定されており、`kill(pid, 0)` は POSIX 上「シグナルを送らずプロセスの生存確認だけを行う」liveness probe である。** すなわちこの 3 箇所自体は「検出時の self-kill 実行地点」ではなく、**「(おそらく直前で sysctl 等により取得した) 対象 pid が現在も生存しているかを確認するだけの処理」**である可能性が高い。実際の self-kill (もし存在するなら) は別の未発見の経路 (`__stack_chk_fail`, `brk` 2 箇所, あるいは今回到達できなかった残りの CFF パス) にある可能性が高く、**そちらは今回のエミュレーションでも到達できておらず未確定**。
pid の値 (常に `0x0` として観測) はエミュレーションのアーティファクトである可能性が高い。詳細は §4。
---
## 4. `pid` フィールドが常に `0` である理由についての所見 (未確定)
`kill`/`sysctl` の pid フィールドは静的にも動的にも「CFF 上流の未追跡レジスタ由来」であり、追跡を試みたが以下が判明した:
- raw `getpid()` (syscall #0x14=20) の呼び出し箇所は 4 つ確定した (`0x1007c4824`, `0x1007c9a74`, `0x1007ccf64`, `0x1007cdce4`)。
- しかし今回のエミュレーション実行 (`emulate.py`) では、この 4 箇所のいずれも一度も実行されなかった (ログに `getpid` 呼び出しの痕跡なし)。
- つまり、エミュレーションが辿った CFF パス上では、`kill`/`sysctl` に渡される pid フィールドは一度も `getpid()` の戻り値で上書きされておらず、**メモリ初期値 (ゼロ) がそのまま使われた**と考えられる。
- 実機では、同じ経路が実行される前に (エミュレーションでは辿れなかった別の分岐で) `getpid()` が呼ばれ、その結果が該当スロットにキャッシュされている可能性が高い。**したがって「pid=0」は実機の挙動ではなく、今回のエミュレーションが辿ったパスの制約によるアーティファクトである可能性が高いというのが誠実な結論であり、実機の pid 値そのものは未確定。**
---
## 5. エミュレーションで実際に到達できた実行順序 (動的、確定)
`initGenAudioH` 本体をエントリから実行した際に実際に観測された呼び出し順序 (import stub 名 / raw syscall のみ抜粋、mutex/malloc 等の C++ 内部処理は省略):
```
CFCopyHomeDirectoryURL
→ CFURLCopyFileSystemPath
→ CFStringGetCString (buf に "/var/mobile" 相当をコピー、bufsize=0x400)
→ CFRelease ×2
→ __snprintf_chk (フォーマット文字列は未確定)
symlink(path1=24byte非ASCII列, path2="") ← §1参照、未確定
__error ×5 ← errno 参照 (symlink 失敗後の典型パターン)
_dyld_image_count → 1
_dyld_get_image_name → (エミュレーション上のダミーパス)
kill(pid=0, sig=0) ← §3参照、確定 sig=0
__sysctl ×3 (mib=[1,14,1,0]) ← §2参照
--- (mutex/malloc/operator_new/operator_new_array 等の C++ オブジェクト構築が多数) ---
strlen ×1, std::__1::random_device 系 ×3 ← C++ 標準ライブラリの乱数エンジン初期化 (§6のクラッシュ修正で到達)
kill(pid=0, sig=0) ← 2回目
__sysctl ×3 (mib=[1,14,1,0]) ← 2セット目
--- (再び mutex/malloc/operator_new/uuid_generate 等) ---
kill(pid=0, sig=0) ← 3回目 (3箇所全て確認)
__sysctl ×3 (mib=[1,14,1,0]) ← 3セット目
--- ここでチェックサム/整合性検証と推測される長大なループに入り、425,000,000 命令超 (実測タイムアウト予算の大半) を消費しても終了せず ---
```
**`getenv` / `dlopen` / `dlsym` / `opendir` / `readdir` / `objc_getClass` / `pthread_create` は、今回のいずれのエミュレーション実行でも一度も呼ばれなかった。** これらは上記の長大ループのさらに先にあると推定される。
### 5-1. 到達できなかった長大ループの構造所見 (静的、確定できる範囲のみ)
エミュレーションが停止した PC 範囲 (`0x1007cec00`〜`0x1007cf200` 付近) を静的に確認したところ、以下が読み取れた:
- `x22+0x460` を「インデックス」、`x22+0x468` を「終端」として比較しながら 1 バイトずつ処理する古典的なバイトループ構造 (`ldrb`→累積→インデックス+1→比較→分岐) を CFF 越しに確認できる (`0x1007ced48`〜`0x1007ced84` 付近)。
- 累積対象バッファのポインタ (`x22+0x470`) は `x19 + 0x161a0` (関数フレーム内のローカルスタックバッファ) から供給されていることを確認した (`0x1007cf6c4`〜`0x1007cf6c8`)。**すなわちこのループはホワイトボックステーブル領域 (`0x100f70000`..`0x101192000`) を直接舐めているのではなく、スタック上のローカルバッファに対するチェックサム/ハッシュ計算である**ことが確定した (何がそのバッファにコピーされているかは未確定)。
- ループの長さ・終了条件を決める式の一部に、ホワイトボックス領域内の定数 `0x1011239a8` (`__DATA.__data`) への直接参照が 1 箇所存在することを確認した (`0x1007cf35c`〜`0x1007cf364`)。ただしこの値がループの終了条件そのものに使われているのか、単なる補助定数なのかは MBA (mixed boolean arithmetic: `(a & ~b) - (~a & b)` 形式の XOR 難読化) に埋め込まれており、追加のシンボリック実行なしでは確定できない。**未確定。**
- `[x22, #0x468]` (ループ終端) 自体への `str` 命令は関数全体を通してこのベースレジスタでは一度も出現しない (`grep` で確認済み、CFF による別ベースレジスタへのエイリアシングが濃厚)。ただし `0x1007cf3b0`〜`0x1007cf3e8` に、`([x22,#0x43c] << 2)` と `[x22,#0x468]` (loop 終端値そのもの) を比較して分岐する MBA 難読化された等値比較を確認した。**これは確定的に読める事実として**、`[x22, #0x468]` (ループ終端バイト数) は `[x22, #0x43c]` (何らかの「ワード数」カウンタ) の 4 倍と一致するように設計されていることを意味する。ただし `[x22, #0x43c]` 自体の由来 (何をカウントした値か) は本調査では追跡できず未確定。
- したがって「これはホワイトボックステーブルの改ざん検知チェックサムである」という解釈は**推測レベル (中程度の確度)** に留め、確定事実としては書かない。「ループはワード数カウンタの4倍のバイト長を持つバッファをバイト単位で走査し、加算/減算の2アキュムレータに累積している」という構造自体は確定情報として上記の通り記載する。
---
## 6. エミュレーション基盤側の課題と対処 (参考、アンチデバッグ本体とは別件)
`build_image.py` の dyld chained-fixups 解決処理で、`__stubs` に収録されていない libc/libc++ シンボル (`strlen`, `sprintf`, `sscanf`, `std::__1::random_device` のコンストラクタ/`operator()`/デストラクタ等、計 3331 種) への直接呼び出しが、初期化されていない `__FAKEBIND` データセルへ分岐してクラッシュする問題があった。`get_cell_map()` で「セルアドレス→元シンボル名」の対応表を復元し、`__FAKEBIND` 領域全体 (`0x7fb000000000`〜`0x7fb000200000`) にレンジフックを 1 本張って未知の外部呼び出しをすべて「シンボル名をログして 0 を返す」汎用フォールバックにルーティングすることで解消した。個別にアドレス単位でフックを 3331 本張る実装は Unicorn 側のフック探索コストにより実行速度が 40 倍以上悪化したため採用しなかった (詳細は `emulate.py`/`build_image.py` の `get_cell_map`/`hook_generic_fakebind` 実装コメント参照)。
---
## 7. 未確定のまま残る項目 (正直な申告)
以下は本調査でも解決できなかった。想像で埋めていない。
- `getenv`/`dlopen`/`dlsym`/`opendir` に渡される実引数文字列 — **今回のエミュレーションで当該コードパスに到達できなかった**ため、旧ドキュメントの「`DYLD_INSERT_LIBRARIES` 推定」を含め全て未確定のまま。
- dyld image walk (`_dyld_get_image_name` 等) での比較先文字列 (`frida`/`substrate` 等) — 呼び出し自体は確認したが比較先文字列は未確定。
- `symlink()` の path1 引数 (24 バイトの非 ASCII 列) の意味・デコード方法 — 固定 XOR/rotate では printable にならず、実行時ランダム値 (uuid 由来?) である可能性を指摘するに留める。未確定。
- `kill(pid, 0)` に渡る実際の pid 値 — エミュレーションでは終始 `0` だったが、これは §4 で述べた通りアーティファクトの疑いが強く、実機の値は未確定。
- **本当の self-kill (非ゼロ signal) 経路の有無と場所** — 確認できた 3 箇所の `kill` は全て `sig=0` (liveness probe) であることが確定した。旧ドキュメントが想定していた「検出時に SIGKILL/SIGABRT で自殺する」経路は、少なくともこの 3 箇所ではない。他に存在するかどうかは未確定 (`__stack_chk_fail` および `brk` 2 箇所 (`0x1007d98b0`, `0x1007d9a68`) は依然として self-terminate 候補だが、今回追加の解析はしていない)。
- `__sysctl` 10 箇所中 1 箇所 (`0x1007c499c`) の MIB 実値 — 到達できず未確認 (他 9 箇所と同一と推定されるが未確認)。
- §5-1 で触れた長大ループが何を計算しているか (チェックサム/ハッシュか、他の用途か) — 構造の一部 (スタックバッファに対するバイト単位の累積処理、終了条件式にホワイトボックス領域内定数への参照が 1 箇所ある) までは確定したが、意味付けは未確定。
---
## 8. まとめ (今回判明した / 訂正された事実)
| 項目 | 結論 | 確度 |
|---|---|---|
| raw SVC 総数 | **35** (旧ドキュメント通り) | 確定 |
| raw SVC 内訳 | `write`×4, `open`×1, `close`×1, `unlink`×7, `getpid`×4 (新規特定), `access`×3, `kill`×3, `symlink`×2, `__sysctl`×10 (旧ドキュメントの「12」を訂正) | 確定 |
| `kill` の signal 値 | **3 箇所全て `sig=0`** (liveness probe。SIGKILL/SIGABRT ではない) | 確定 (静的+動的一致) |
| `sysctl` の MIB 構造 | `{CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1, pid}` | 確定 (9/10 サイトで動的一致) |
| `sysctl`/`kill` の pid 実値 | 不明 (エミュレーションでは 0 だがアーティファクトの疑い) | 未確定 |
| `getenv`/`dlopen`/`dlsym`/`opendir` の引数文字列 | 到達不能で未確定 | 未確定 |
| dyld walk 比較文字列、symlink path1 のデコード | 未確定 | 未確定 |
| 真の self-kill (非ゼロ signal) 経路 | 未発見 | 未確定 |
---
## 付録: 生成物一覧
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/common.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/dump_func.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/dump_wrapper.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/find_loops.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/scan_svc_context.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/dump_loop_region.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/build_image.py`
- `/home/vscode/app/targets/znca_ios_3.4.1/analysis/antidebug/emulate.py`
+164
View File
@@ -0,0 +1,164 @@
# znca iOS 3.4.1 `_gen_audio_h` 内部構造リバース
- 対象バイナリ: `/home/vscode/app/targets/znca_ios_3.4.1/Crew` (arm64 Mach-O, znca/Nintendo Switch Online iOS 3.4.1)
- 対象関数: `_gen_audio_h` VA `0x10090f20c` - `0x100a208fc`(サイズ `0x1116f0` = 1,120,496 バイト ≈ 1.07 MiB、命令数 279,996、`_gen_audio_h2` とテール結合)
- 前提ドキュメント: `docs/spec/znca_ios_whitebox_layout.md`, `docs/spec/znca_ios_f_generation.md`
- 使用ツール: Python + capstone(4byte固定スロット逐次デコード)、CFF deflatten ツールチェーン(`analysis/cff/*.py` を移植した `analysis/gen_audio_h/*.py`)、lief(Mach-O / bindings 解決)
- 制約: 追跡不可の項目は「追跡不可」と明記し、推測では埋めない。
新規スクリプト一式: `/home/vscode/app/targets/znca_ios_3.4.1/analysis/gen_audio_h/`
(`common.py`, `disasm_body.py`, `find_dispatch.py`, `resolve_edges.py`, `build_cfg.py`, `trace_simd_loads.py`,
`dump_simd_zone_ops.py`, `find_blob_touch_range.py`, `find_sha256_calls.py`, `scan_k_table_refs.py`,
`check_imports_and_bl_targets.py`, `resolve_stub_targets.py`, `find_pthread_create.py`,
`inspect_thread_routine.py`, `resolve_thread_routine_calls.py`)
## 0. 関数の基本構造(前提の再確認)
プロローグ (func+0x0 〜 func+0x24):
```
stp x28, x27, [sp, #-0x60]!
stp x26, x25, [sp, #0x10]
stp x24, x23, [sp, #0x20]
stp x22, x21, [sp, #0x30]
stp x20, x19, [sp, #0x40]
stp x29, x30, [sp, #0x50]
add x29, sp, #0x50
sub sp, sp, #0x45, lsl #12 ; -0x45000
sub sp, sp, #0x380 ; -0x380
mov x19, sp ; x19 = 呼び出しごとのフレームベース
```
- ローカルスタックフレームは **0x45380 バイト(283,520 バイト、約 276.9 KiB)** の巨大な領域。
- `x19 = sp`(サブ2回適用後)を全編を通した基準レジスタとして使用。`x29 = x19 + 0x453d0`(旧sp+0x50)。
- 以降、x20〜x28・x15 等の「エイリアスレジスタ」が `add xD, x19, #H, lsl#12` + `add xD, xD, #L` の2命令ペアでプロローグ直後に多数構築される(func+0x28〜)。CFF (Control-Flow-Flattening) による分岐先の中でも同型の `add` ペアが繰り返し出現する。
- CFF 統計(`find_dispatch.py` / `resolve_edges.py` / `build_cfg.py` の結果):
- dispatch サイト 1965(Type B 直接 858 + Type A 間接 1107)、distinct state slot 1313
- 書き込み(edge)総数 2889、うちコンパイル時定数に解決できたもの 2203、**解決不能(動的/データ依存)686 件(23.7%)**
- pseudo-block(leader)37,439、CFF 分岐エッジ 7376、動的エッジ 686、invalid 381、直接分岐 28,161、`bl` 呼び出しサイト 573(distinct 呼び出し先 64)
- `initGenAudioH`(先行セッションの `analysis/cff/`)の動的エッジ比率 ~1.8% と比べて `_gen_audio_h` は 23.7% と大幅に高く、より入力依存の強い分岐構造を持つ。
---
## 1. SIMD `ldr qN, [Xbase, Xindex]` 8箇所のベースレジスタ追跡
前セッションの `scan_gen_audio_h_refs.py` が検出した「レジスタ+レジスタ形式の 128bit ロード」8箇所すべてについて、`trace_simd_loads.py`(CFG述語ウォークによる後方レジスタ定義追跡、CFF解決済みエッジのみを辿る)で base/index レジスタの由来を特定した。結果は全件、**whitebox blob(`0x100f70000`-`0x101192000`)ではなく `x19`(= 呼び出し毎スタックフレーム)由来**であり、事前仮説(blob直接インデックス)は明確に反証された。
| # | site (VA) | func offset | base 構成 | base = x19+offset | index 構成 |
|---|---|---|---|---|---|
| 1 | 0x10095c6c8 | +0x4d4bc | `add x9,x19,#0x43,lsl#12` / `add x9,x9,#0x68` | **x19+0x43068** | `lsl x8,x11,#3` |
| 2 | 0x1009e73b0 | +0xd81a4 | `add x9,x19,#0x44,lsl#12` / `add x9,x9,#0x5c8` | **x19+0x445c8** | `lsl x8,x11,#3` |
| 3 | 0x1009ec2e0 | +0xdd0d4 | `add x9,x19,#0x3b,lsl#12` / `add x9,x9,#0xf8c` | **x19+0x3bf8c** | `lsl x8,x11,#3` |
| 4 | 0x1009ece78 | +0xddc6c | `add x9,x19,#0x3c,lsl#12` / `add x9,x9,#0xc` | **x19+0x3c00c** | `lsl x8,x11,#3` |
| 5 | 0x1009ef5a4 | +0xe0398 | `add x10,x19,#0x3b,lsl#12` / `add x10,x10,#0xfcc` | **x19+0x3bfcc** | `lsl x9,x8,#3` |
| 6 | 0x100a0d8d4 | +0xfe6c8 | `ldr x9,[x19,#0x49f8]`(ポインタ間接) | **x19+0x49f8 の中身**(未解決) | CFFの分岐先2系統で `mov x8,#0` または `ldr x8,[x19,#0x4a28]` — **実行時パス依存(動的)** |
| 7 | 0x100a1cdac | +0x10dba0 | `sub x9,x29,#0xa8` = x19+0x453d0-0xa8 | **x19+0x45328** | `lsl x8,x11,#3` |
| 8 | 0x100a1dff8 | +0x10edec | `ldr x8,[x19,#0x11c8]`(ポインタ間接) | **x19+0x11c8 の中身**(未解決) | CFF述語チェーンを4ホップ遡っても predecessor が尽き `UNRESOLVED(no predecessors found)` — **追跡不可** |
観測事実:
- 8箇所中 **6箇所**(#1-5, #7)は `x19` からの直接オフセットで、その値はフレーム末尾寄りの狭い帯 **`[0x3bf8c, 0x45328]`(約 37.8 KiB, フレーム全体 276.9 KiB の末尾約 13.6%)** に集中する。
- 残り **2箇所**(#6, #8)は `x19+0x49f8` / `x19+0x11c8` という、フレーム冒頭寄り(それぞれオフセット 18,936 / 4,552 バイト)の**ポインタスロット**から値をロードし、それを base として使う間接参照。このポインタの指す先(whitebox blob 内か、フレーム内の別領域か、ヒープか)は **本セッションでは追跡不可**。
- index レジスタは全 8 箇所とも `xN << 3`(8バイト単位)。読み出しは 128bit(16バイト)幅なので、**8バイト刻みの隣接2エントリをまとめて1回のSIMDロードで取得する**アクセスパターンになっている(AES系Tテーブル実装で隣接2ワードをまとめて読む手法に外形的に類似するが、内容がテーブルであることを裏付けるデータ検証は本セッションでは未実施 — 外形パターンの指摘に留める)。
- `[0x3bf8c, 0x45328]` 帯を x19 リテラルオフセットで書き込む命令は関数全体を通して **0件**(3658件の全 x19+imm STR/STUR/STP を走査、到達範囲は `[0x4, 0x7fa8]` のみ)。一方、`add xD,x19,#H,lsl#12[+L]` 型のエイリアスレジスタ経由では **118件のメモリ操作(load 63 / store 54)がこの帯に到達**することを確認(`dump_simd_zone_ops.py` 出力、`analysis/gen_audio_h/simd_zone_ops_output.txt`)。うち目立つパターンとして、stack_off `0x412e8+{0x7f,0xff,0x17f,...,0xaff}`(128バイト間隔、22件)に `strb wzr, [x8, #N]` が連続する箇所がある — 128バイト境界ごとの末尾1バイトをゼロクリアする配列初期化に見えるが、**用途(パディング長マーカー等)は未確認、推測を避け「観測されたパターン」として記録するに留める**。
- `pthread_create` 呼び出し(下記)の `arg` は `x19+0x3fe4f` で、これも上記帯の範囲内 `[0x3bf8c,0x45328]` に収まる。ただし後述の通り、この呼び出し先スレッドルーチンは crypto/table 処理とは無関係(CFStringGetCString 等ファイルパス系API呼び出しのみ)と判明しており、**このアドレス一致は偶然の可能性が高く、テーブル生成との関連性の証拠にはならない**(前セッション時点での仮説は本セッションで反証・撤回)。
**結論(項目1)**: 8箇所の SIMD ロードのうち 6 箇所は blob ではなく `_gen_audio_h` 自身の巨大ローカルスタックフレーム(277KiB)末尾約38KiBの領域を読む。残り2箇所はポインタ間接であり、そのポインタの指す先(whitebox blobである可能性を含む)は**追跡不可**。この帯を linear body 内で literal に書き込む命令が存在しないため、**この帯へのデータ供給元(blob からの一括コピーの有無を含む)は本セッションでは特定できていない=追跡不可**。
---
## 2. `_set_platform_for_gen_audio` (0x1007788ec) 呼び出し時の入力
呼び出しサイト: `0x1009593e8`(`_gen_audio_h` 内、単一の call site)。
- この call site は CFF による1命令だけの pseudo-block(`bl` のみ)であり、直前 400 命令の生走査でも x0/w0 への有意な書き込みは検出されなかった。
- callee 自体(`0x1007788ec` 〜 `ret`@`0x100778a3c`、約 0x150 バイト)を実際に逆アセンブルした結果、**x0 レジスタを一切読まない**ことを確認した。呼び出し内部は `__bss` 上のグローバル(VA ≈ `0x1013d5d30`)のみを操作し、`bl 0x1007e1998(ptr, mode, flag)` を固定の即値ペア(`w1=3,w2=1` → `w1=0xf,w2=0`)で 3 回呼ぶだけの、**実質パラメータなし(自己完結型)の関数**であることが確認できた。
**結論(項目2)**: `_gen_audio_h` からのこの呼び出しは、可変の「プラットフォームID」等を x0 経由で渡していない。callee は入力非依存。
補足: 隣接するアドレス `0x100778a60` に x0 を消費する別関数(`mov x19,x0` あり)が存在し、命名規則(`znca_ios_f_generation.md` の RVA テーブル)から見て `_set_platform_for_gen_audio2` に相当する可能性が高いが、これは `_gen_audio_h` からは呼ばれていない(未検証の別経路)。
---
## 3. 9-subtable 仮説の裏取り結果
前セッションの仮説: whitebox blob の pure-table 領域サイズ(1.76 MiB)÷ Netflix TFIT-AES11 テーブルセットサイズ(203,904 B)≈ 9.06 という統計的推測を、8 個の SIMD ロード=「プラットフォーム別サブテーブル選択」の物理的根拠として補強できるか、というのが検証対象だった。
- 項目1の追跡結果により、**8箇所の SIMD ロードのうち少なくとも6箇所は blob を直接インデックスしていない**(`_gen_audio_h` 自身のスタックフレーム末尾約38KiB帯を読む)ことが確定した。したがって「8個のSIMDロード = 9サブテーブルのプラットフォーム選択機構」という具体的メカニズムは**反証(refute)**する。
- 残り2箇所(ポインタ間接、#6・#8)については、そのポインタが最終的に whitebox blob 内アドレスを指すか否かは**追跡不可**であり、9-subtable仮説そのもの(blobサイズの統計的根拠)を積極的に否定する材料ではない。あくまで「今回追跡した8個のSIMDロードがその根拠にはならない」という限定的な反証にとどまる。
- blob サイズ比(1.76 MiB ÷ 203,904 B ≈ 9.06)という統計的観察自体は本セッションで再検証していない(`znca_ios_whitebox_layout.md` に記載のまま、独立した弱い状況証拠として存置)。
**結論(項目3)**: 8-SIMD-load による 9-subtable 選択という具体的メカニズム仮説は**反証**。統計的サイズ比の仮説自体の真偽は**追跡不可(本セッション範囲外)**。
---
## 4. whitebox blob への最初/最後の参照命令オフセットと phase 分割
`find_blob_touch_range.py`(全 `ADRP+ADD` によるデータアドレス形成のうち、着地先が `[0x100f70000, 0x101192000)` に入るものを全走査)の結果:
- **総ヒット数 14,750 件、distinct ターゲット 1,194 個**
- **最初の参照**: `0x10090f40c`(func+0x200) → target `0x1010b59a0`(blob+0x1459a0)
- **最後の参照**: `0x100a20698`(func+0x11148c) → target `0x101034c58`(blob+0xc4c58)
- 参照は関数の **func+0x200 から func+0x11148c まで、関数全体(0x1116f0)のほぼ全域(99.97%)** に渡って分布しており、20分割したバケット密度もほぼ一様(各バケット 400〜985件、明確な偏りなし)。
上位ターゲットの内訳(`Counter` 集計):
```
0x101034204 (blob+0xc4204) count=1385 (9.4%) ← CFF_STATE_VAR_MAIN(initGenAudioH解析で既知の共有CFF状態変数)
0x101034810 (blob+0xc4810) count=518 (3.5%)
0x101034af0 (blob+0xc4af0) count=502 (3.4%)
... (上位20件すべてが blob+0xc4000〜0xc5000 の約3.5KiB帯に集中)
上位20件の累積: 65.2%
distinct ターゲットのうち1回しかヒットしないもの: 788 / 1194
```
- 上位ヒットは全て `znca_ios_whitebox_layout.md` が「CFF-plumbing」(17.4%、間接分岐用ステートアイランド)と分類した領域に一致しており、**genuine な whitebox テーブルセル参照ではなく、CFF ディスパッチ機構自体のステート変数アクセスである**ことがほぼ確実。
- 1回しかヒットしない 788 個の distinct ターゲットの中に、真のテーブルセル参照("pure table" 82.6% 領域内のもの)が含まれている可能性はあるが、それを CFF-plumbing の残余ノイズと区別するには前セッションのエントロピーベース分類(バイト単位の pure-table / plumbing 境界データ)との突合が必要であり、**本セッションでは実施できていない(追跡不可)**。
- 上記のとおり density に明確な偏りが無いため、**serialize/hash/encrypt/finalize のような phase ブロックへの区分けは、この touch-density データからは視認できない**。CFF による全域フラット化のため、素朴な「命令オフセット順=処理順」という前提も成立しない可能性が高い。
**結論(項目4)**: 最初/最後の blob 参照命令オフセットは特定できた(func+0x200 〜 func+0x11148c)が、その大半(65%超)は CFF ディスパッチ用ステート変数アクセスであり、genuine なテーブル参照ではない。phase 分割は**視認できず、追跡不可**として残す。
---
## 5. SHA-256 K[64] ラウンド定数を起点とした CryptoPP Merkle-Damgard 実装の探索
- バイナリ内に標準 SHA-256 `K[64]` テーブル(`0x428a2f98, 0x71374491, ...`)の完全一致コピーが **3箇所**存在することを確認: `0x100cae7e0`, `0x100cbbcc8`(いずれも `__TEXT`)、`0x100f77da0`(whitebox blob 内、CFF-plumbing アイランド)。
- `__TEXT` 内の2コピーはそれぞれ1つの小関数から ADRP+ADD で参照されている(`0x1006c8c8c` サイズ 0x1f0、`0x1006ec35c` サイズ 0x99c)。
- `_gen_audio_h` の全 `bl` ターゲット(distinct 64件)を走査した結果、**この2関数はどちらも呼ばれていない**ことを確認(`find_sha256_calls.py`)。
- `_gen_audio_h` 自身の ADRP+ADD ターゲット(distinct 11,944件、`scan_k_table_refs.py`)の中に、上記3つの K[64] コピーへの参照は **0件**。K[64] の 256バイトパターン(および先頭8ワードの部分パターン)とのバイト一致も**0件**。
- CommonCrypto の `_CC_SHA256` インポートシンボルも確認したが、その `__stubs` トランポリン(GOTスロット `0x100e57288`)を `_gen_audio_h` が `bl` で呼び出している事実は**なし**(`resolve_stub_targets.py`)。
追加調査: `_gen_audio_h` 内で唯一の `pthread_create` 呼び出し(call site `0x10093b55c`, func+0x2c350)を発見し、その `start_routine = 0x1006f418c` を検証した。
- `lief.function_starts` によるバウンド: `0x1006f418c` - `0x1006f444c`(サイズ 0x2c0 = 704 バイト、176命令)。
- 同ルーチン内の ADRP+ADD データ参照は 1 件のみで、それは `0x101034204`(= 上記 CFF_STATE_VAR_MAIN、テーブルではない)。
- `bl` 呼び出し先は3件で、`__stubs` トランポリン解決の結果、以下のインポートシンボルであることを確認した(`resolve_thread_routine_calls.py`):
- `0x100c32fdc` → `_CFArrayGetValueAtIndex`
- `0x100c330a8` → `_CFStringGetCString`
- `0x100c33414` → `_NSSearchPathForDirectoriesInDomains`
これは **ファイルパス/ディレクトリ解決系の処理**(例: Documents/Caches ディレクトリパスの取得)であり、暗号処理・whitebox テーブル生成とは無関係と判断できる。前セッションで立てていた「この pthread がスタックスクラッチ帯 `[0x3bf8c,0x45328]` を埋めている可能性」という仮説は、**本セッションで明確に反証・撤回する**(`arg` アドレスが当該帯に落ちるのは偶然の一致であり、スレッドルーチン自体はその帯を全く触れない)。
**結論(項目5)**: `_gen_audio_h` 自身の線形コード本体には、SHA-256 K[64] テーブルへの参照も `_CC_SHA256` 呼び出しも**存在しない**(十分な根拠を伴う確認済みの否定的所見)。唯一の `pthread_create` 呼び出しも SHA-256/whitebox 処理とは無関係と判明した。したがって、`docs/spec/znca_ios_f_generation.md` に記載の「CryptoPP SHA-256 が使われている可能性」は、**少なくとも `_gen_audio_h` の直接呼び出しグラフ(bl 64種、pthread routine 含む)の範囲では確認できず**、以下のいずれかであると考えられる:
(a) SHA-256 は使われておらず別のプリミティブ(whitebox テーブル自体がハッシュ相当の拡散/混合を担う設計)である、
(b) `_gen_audio_h` からさらに深い間接呼び出し(未解決の `bl` 先の先、あるいは項目1で追跡不可だったポインタ間接ロードの供給元)に存在する、
のいずれかであり、**本セッションでは両者を区別できず、追跡不可**として残す。
---
## 総括
| 項目 | 結果 |
|---|---|
| 1. SIMDロードの base 由来 | 6/8 は `_gen_audio_h` 自身のスタックフレーム末尾 ~38KiB 帯 (`x19+0x3bf8c`〜`x19+0x45328`)。2/8 はポインタ間接で指し先未解決(追跡不可)。**blob 直接インデックスは確認されず**。 |
| 2. `_set_platform_for_gen_audio` 入力 | x0 は読まれない。callee は自己完結型(`__bss` グローバルのみ操作)。可変プラットフォームIDは渡されていない。 |
| 3. 9-subtable 仮説 | SIMDロード起因の具体的メカニズムとしては**反証**。blobサイズ比の統計的仮説自体は再検証しておらず**追跡不可**。 |
| 4. blob touch 範囲・phase | 参照は func+0x200〜func+0x11148c(ほぼ全域)。65%超が CFF ステート変数アクセスで table 参照ではない。phase 分割は**視認できず追跡不可**。 |
| 5. SHA-256 K[64] 起点探索 | `_gen_audio_h` 本体・唯一の pthread routine ともに K[64] 参照/CC_SHA256 呼び出しなし(根拠十分な否定的所見)。深い間接呼び出し先の可能性は**追跡不可**。 |
追跡不可として明示的に残した項目:
- SIMD site #6 (`x19+0x49f8`) と #8 (`x19+0x11c8`) のポインタが指す先。
- スタックスクラッチ帯 `[0x3bf8c,0x45328]` へのデータ供給元(blob からの一括コピーの有無を含む)。
- `strb wzr` 128バイト刻みパターンの意味論的用途。
- 1回のみヒットする 788 distinct blob ターゲットのうち、真のテーブルセル参照と CFF-plumbing 残余ノイズの切り分け。
- SHA-256(またはCryptoPP相当のMerkle-Damgard実装)が存在するとすれば、それが存在しうるさらに深い間接呼び出し経路。
+378
View File
@@ -0,0 +1,378 @@
# znca iOS 3.4.1 — `+[VoIPClient initGenAudioH]` 本体の CFF de-flatten
対象: `/home/vscode/app/targets/znca_ios_3.4.1/Crew` (arm64 Mach-O, `__TEXT` vmaddr = `0x100000000`)
本体関数: `0x100783fc8` .. `0x1007d594c` (0x51984 バイト、約335 KiB)。前提となる静的解析は
`docs/spec/znca_ios_init_gen_audio_h_static.md` を参照。
このドキュメントは前セッションで「単一 basic block に見える巨大関数」と判明した本体を、
capstone ベースの自作パーサで **de-flatten(制御フロー平坦化の解除)** した結果をまとめる。
補助スクリプトはすべて `targets/znca_ios_3.4.1/analysis/cff/` に配置:
| ファイル | 役割 |
|---|---|
| `common.py` | セグメント/ファイルオフセット解決、共通定数 |
| `disasm_body.py` | 関数域を capstone で線形逆アセンブルし `insns.pkl` にキャッシュ |
| `explore_wrapper.py` | `+[VoIPClient initGenAudioH]` ラッパー (`0x100a29528`) で CFF イディオムの仮説を検証 |
| `find_dispatch.py` | CFF ディスパッチイディオム (Type A / Type B) を全走査で検出、`target_base` を解決 |
| `resolve_edges.py` | 各ディスパッチ site の state slot への全 writer を走査し、定数なら `target_base+value` に解決 |
| `build_cfg.py` | 上記を統合し pseudo-BB グラフを構築 (`cfg.pkl`) |
| `export_dot.py` | `cfg.pkl` から DOT を出力 (`cfg_full.dot` + zone 別 `cfg_zone_<reg>.dot`) |
| `list_real_effects.py` | 「本当に意味のある」15 個のディスパッチ効果 (非ゼロ定数 2 件 + 動的 13 件) を再現表示 |
再実行方法:
```bash
cd /home/vscode/app/targets/znca_ios_3.4.1/analysis/cff
python3 disasm_body.py # insns.pkl 生成 (要 force=True の初回のみ)
python3 build_cfg.py # cfg.pkl 生成
python3 export_dot.py # DOT 出力
python3 list_real_effects.py
```
---
## 1. CFF ディスパッチイディオムの完全な解読
前セッションで確認した idiom:
```
ldr xSTATE, [base, #off] ; state slot を読む
adr xA, #TT ; TT = このイディオム自身の近傍アドレス
[ldrsw xB, [xA]; add xA, xB, xA]; ← Type A のみ (TT 直後に埋め込まれた delta word を加算)
and xC, xA, xSTATE
mov xD, #2
mul xC, xC, xD
eor xA, xA, xSTATE
add xA, xA, xC ; xA = (target_base ^ state) + 2*(target_base & state)
br xA
```
`a^b + 2*(a&b) == a+b` という桁上げの恒等式を使って `xA = target_base + state` を計算しているだけ、
というのが前セッションからの仮説だった。**今回、`0x100a29528` ラッパーの手計算トレースで厳密に確認した**:
`state=0` のとき `target_base = TT+4 = 0x100a29564` へ分岐し、実際にその番地の命令列と一致した。
2 つの変種が存在する:
- **Type A** (`ldrsw` による間接テーブル経由): `target_base = TT + word_at(TT)` (符号付き 32bit)。**317 件**。
- **Type B** (直接): `adr` の結果 `TT` 自体が `target_base`。**377 件**。
全 **694 件**のディスパッチ site をバイナリ全域から検出した (`find_dispatch.py`)。
いずれも `word_val` は事実上常に `4`、すなわち `target_base` は「このイディオム自身の直後の命令アドレス」
(= state=0 のときの通常フォールスルー) に一致する — obfuscator は per-site に自分専用の delta table を
埋め込んでいるが、その値はほぼ常に「次の命令へフォールスルーするための自明値」になっている。
### state 変数の実体は「関数プロローグで確保した専用スタックスロット」
**重要な訂正**: 前回セッションの静的解析ドキュメントは「`__DATA.__data:0x101034204` (628 refs) が CFF
ディスパッチ変数」と推定していたが、**これは誤り**だった。実際に idiom を機械的に検出して state レジスタの
出所を後方追跡した結果 (`find_dispatch.py` の `state_load_addr`/`state_src_operand`)、694 件の site が
参照する state slot は **すべて `[baseReg, #imm]` という形のスタックローカル変数**であり、`0x101034204`
のような固定グローバルアドレスを参照する site は **1 件も存在しない**。
`0x101034204` を直接検証すると:
```
adrp x9, #0x101034000
add x9, x9, #0x204
str w8, [x9] ; ← 常にレジスタ値の書き込みのみ (628/629回)
```
このアドレスへの参照は本体全域で **629 回すべてが書き込み、読み込みは 0 回**。つまり
**`0x101034204` は CFF ディスパッチ変数ではなく、"書きっぱなしの囮シンク" (dead-store 難読化)** である。
同様に、前回ドキュメントが「state 変数の亜種」と推定した以下のグローバルアドレスも、実際には
**読み込み優勢** (書き込みはたかだか 0〜1 回) であり、これも CFF state ではなく **通常のデータ/設定値読み出し**
である可能性が高い:
| アドレス | reads | writes | 前回の推定 | 今回の判定 |
|---|---|---|---|---|
| `0x101034204` | 0 | 629 | CFF state var (628 refs) | **書き込み専用の囮シンク** |
| `0x10113a520` (ページ `0x10113a000`) | 123 | 1 | state var 亜種 | **読み出し優勢、CFF ではない** |
| `0x10103d998` (`0x10103d000`) | 27 | 0 | state var 亜種 | **読み出し専用** |
| `0x10103f9e0` (`0x10103f000`) | 43 | 0 | state var 亜種 | **読み出し専用** |
| `0x101084300` (`0x101084000`) | 18 | 0 | state var 亜種 | **読み出し専用** |
| `0x10110e020` (`0x10110e000`) | 24 | 1 | state var 亜種 | **読み出し優勢、CFF ではない** |
### 真の state slot: 9 個の「ゾーン」レジスタ + 個別オフセット
関数プロローグ (`0x100783fc8`〜`0x100784028`) は `sub sp, sp, #0x167f0` (約90KiB) の巨大フレームを確保した後、
即値オフセットが 12bit に収まるように **9 個のゾーンベースレジスタ**へ分割している:
```
0x100783fec: mov x19, sp ; zone x19 = +0
0x100783ff0: add x22, x19, #0xc, lsl #12
0x100783ff4: add x22, x22, #0x460 ; zone x22 = +0xc460
0x100783ff8: add x28, x19, #0xa, lsl #12
0x100783ffc: add x28, x28, #0x1e0 ; zone x28 = +0xa1e0
0x100784000: add x24, x19, #6, lsl #12
0x100784004: add x24, x24, #0x204 ; zone x24 = +0x6204
0x100784008: add x25, x19, #4, lsl #12
0x10078400c: add x25, x25, #0xfbe ; zone x25 = +0x4fbe
0x100784010: add x26, x19, #3, lsl #12
0x100784014: add x26, x26, #0xfb0 ; zone x26 = +0x3fb0
0x100784018: add x23, x19, #2, lsl #12
0x10078401c: add x23, x23, #0xfee ; zone x23 = +0x2fee
0x100784020: add x21, x19, #1, lsl #12
0x100784024: add x21, x21, #0xfc7 ; zone x21 = +0x1fc7
0x100784028: add x20, x19, #0xfba ; zone x20 = +0xfba
```
CFF の state slot は、実測では以下のレジスタを base に使う (`x21`/`x23`/`x24` は
dispatch state としては一度も観測されず、単なるデータ用ゾーンとして使われている模様):
| base reg | dispatch site 件数 | 観測アドレス範囲 |
|---|---|---|
| `x19` | 369 | `0x100784064`–`0x1007d45a4` (関数ほぼ全域) |
| `x20` | 68 | `0x1007b2ca0`–`0x1007bf02c` |
| `x22` | 60 | `0x1007cd3d8`–`0x1007d4614` |
| `x25` | 56 | `0x1007b9ab4`–`0x1007cacb8` |
| `x28` | 55 | `0x1007bf658`–`0x1007c66e0` |
| `x14` (別名) | 42 | `0x1007b8f38`–`0x1007d4b20` |
| `x12` (別名) | 23 | `0x1007b943c`–`0x1007d4e4c` |
| `x8` (別名) | 13 | `0x1007b90b8`–`0x1007d5118` |
| `x26` | 5 | `0x1007c966c`–`0x1007c9988` |
| `x13` (別名) | 2 | `0x1007cc53c`–`0x1007d4ff0` |
| `x15` (別名) | 1 | `0x1007d550c` |
**693 個の distinct slot に対して 694 件の dispatch site** — ほぼ 1 サイト 1 専用スロットであり、
少数の共有変数による「単一の巨大 switch」ではなく、**関数全体に散らばった約 700 個の独立した
マイクロ state-machine**として実装されている。これは task 記述にあった「副次ディスパッチ変数ごとに
分離して疑似関数を切り出す」という前提を修正する必要があることを意味する: **明確に分離できる少数の
疑似関数には分割できない**。x19/x20/x22/x25/x28 等の「ゾーン」はアドレス局所性を示すが、`x19` ゾーン
だけで関数のほぼ全域をカバーし、`kill` 呼び出し 3 箇所はいずれも 4〜5 個のゾーンに同時に属する
(後述 §4)。DOT ファイルはゾーン単位でも分割出力したが (`cfg_zone_<reg>.dot`)、これは**疑似関数境界の
確定的な切り分けではなく、あくまで参考情報**として扱うべきである。
---
## 2. de-flatten の結果
`resolve_edges.py` は、694 件の dispatch site が参照する 693 個の state slot それぞれについて、
**関数全体を走査してその slot へ書き込む全命令**を集め、書き込み元レジスタを最大 6 命令分だけ
後方追跡して `mov`/`movz`/`movn`/`movz+movk` 連鎖の定数に還元できるか試みた。
| 分類 | 件数 | 比率 |
|---|---|---|
| state slot への writer 総数 | 714 | 100% |
| うち **定数に解決できた** (`resolved`) | 701 | 98.2% |
| うち **実行時値 (動的、解決不能)** | 13 | 1.8% |
| 解決できたが target が 4byte非整列/範囲外 (`invalid`、後述) | (701 中 10 件を除外) | — |
| 有効な CFF エッジとして採用 | **693** | — |
`build_cfg.py` はこれらを実際の分岐先アドレスに変換し、**通常の分岐命令 (`b`/`b.cond`/`cbz`/`cbnz`/
`tbz`/`tbnz`、10150 件、`bl` 273 件) と統合した pseudo-CFG** を構築した (`cfg.pkl`)。
```
leaders (pseudo-BB 数): 13200
de-flatten された CFF エッジ: 693
直接分岐 (b/b.cond/cbz/...): 10150
bl 呼び出し site: 273
動的 (未解決) エッジ: 13
無効 (整列/範囲外、除外済み): 10
```
### 「無効」エッジについて (10 件)
10 件の writer は、定数への還元には成功したが、結果アドレスが 4byte 非整列 (例: `target_base+3`)
または関数域外になった。サンプル:
```
write=0x1007b9bd8 slot=('x25',232) value=0x3 target_base=0x1007c6864 target=0x1007c6867 (非整列)
write=0x1007c8bcc slot=('x25',840) value=0x1 target_base=0x1007baa28 target=0x1007baa29 (非整列)
```
`(base_reg, offset)` が一致するというだけで「同一の CFF state slot への書き込み」と誤認したケースで、
実際には**関数内の別の無関係なコード領域が、生存期間の終わったスタックスロットを別の目的の
ローカル変数として再利用している**ケース (レジスタ/スタックスロットの再利用は巨大関数の
コンパイラ最適化として一般的)。これらは CFG から除外した。**701 件中 691 件 (98.6%) は 4byte 整列・
関数域内という妥当な結果**であり、手法自体の健全性を裏付けている (§3 でサンプル検証済み)。
---
## 3. 検証: de-flatten されたエッジは実際に「意味のある」コードへ着地するか
`dispatch_edges` からランダムサンプルした 6 件について、write site の直前コンテキストと
target 先頭数命令を確認した。全件で target は自然な「ローカル変数の読み書き・比較」から
始まっており、ゴミ地帯やデータ領域への着地はなかった。例:
```
write=0x100794b84 val=0 slot=('x19', 12144) -> target=0x100794bb4
0x100794b80: cbnz w8, #0x100794d34
0x100794b84: str xzr, [x19, #0x2f70] ; state=0
-> 0x100794bb4: strb wzr, [x21, #0xfa8] ; 自然なコード
-> 0x100794bb8: b #0x100794d40
```
---
## 4. 「本当に意味のある」15 件のディスパッチ効果
de-flatten された 693 件のうち、**689 件は state=0 が書き込まれた結果、target_base (= 直後の命令、
つまり実質フォールスルー) に一致するだけの no-op** であることが判明した (`list_real_effects.py` で
`val != 0` を抽出すると 693 件中わずか **2 件**しかヒットしない)。すなわち **CFF ディスパッチ機構の
99.7% は静的解析・逆コンパイルを妨害するためだけの opaque predicate であり、実行時の制御フローには
何の影響も与えていない**。これ自体が重要な発見であり、335 KiB という関数サイズの大半が
「本物の処理」ではなく「フォールスルーするだけの巨大な迂回機構」で占められていることを示す。
本当に意味を持つのは以下の **15 箇所**のみ:
### 4-1. 非ゼロ定数による実ジャンプ (2 件)
| write addr | value | slot | target |
|---|---|---|---|
| `0x1007c721c` | `4` | `(x25, 1000)` | `0x1007bafb8` |
| `0x1007c9454` | `0x80` | `(x25, 248)` | `0x1007b9b3c` |
いずれも長距離の無条件ジャンプで、着地先は自然なコード (ローカル変数の `and`/`cmp`/`cset` など)。
`b` 命令の代わりにわざわざ CFF イディオムを使って実装された「本物の goto」。
### 4-2. 実行時値による動的分岐 (13 件) — anti-debug/anti-inject との相関
この 13 件こそが、`initGenAudioH` の中で**唯一、実行時の環境によって制御フローが変わる**箇所である。
| write addr | slot | 直前の出所 | 解釈 |
|---|---|---|---|
| `0x1007c8bf8` | `(x25, 624)` | `bl 0x100c33c84` (**`_dyld_image_count`**) の戻り値 `w0` | **ロード済み dylib 数がそのまま次の分岐先を決める** — インジェクトされた dylib (Frida/Substrate) の有無を検知する最有力候補 |
| `0x1007bcae8` | `(x25, 1984)` | `bl 0x100c34980` (**`sel_registerName`**) の戻り値 `x0` | 動的に解決した Objective-C セレクタのポインタ値が分岐先を決める |
| `0x1007bdbf0` | `(x25, 2752)` | 同上 `sel_registerName` (別呼び出し) | 同上 |
| `0x1007caacc` | `(x25, 2456)` | `eor`/`lsr`/`lsl` を連鎖させたローリング XOR 合成 (2 つのローカル値を混合) | マスク/ハッシュ的な合成値。ホワイトボックス関連の可能性が最も高いが、書き込み先は **スタックローカル**でありマップ済みホワイトボックス領域そのものではない |
| `0x1007cab1c` | `(x25, 2528)` | 上記合成の続き (別シフト量での XOR) | 同上 |
| `0x1007d4d48` | `(x20, 128)` | `eor w8, w9, w8` — 2 つの別ゾーン (`x20`/`x21`) 由来の値の XOR | 同系統のマスク合成 |
| `0x1007bba7c` | `(x25, 1240)` | `ldr x8,[x25,#0x4d0]; str x8,[x25,#0x4d8]` (既存ローカル値のコピー) | 出所は本ウィンドウ内では追跡不可 (**追跡不可**) |
| `0x1007bcb50` | `(x25, 2008)` | `sub w8,w8,w9` (2 ローカル値の差分) | 減算結果が分岐先を決める。オペランドの最終出所は未追跡 (**追跡不可**) |
| `0x1007bd1f4` | `(x25, 2544)` | `ldr w8,[x25,#0x9ec]` (既存ローカル値のコピー) | 出所未追跡 (**追跡不可**) |
| `0x1007c7f90` | `(x25, 1304)` | `ldr w8,[x25,#0x510]` (既存ローカル値のコピー) | 出所未追跡 (**追跡不可**) |
| `0x1007c8490` | `(x25, 1376)` | `ldr w8,[x25,#0x558]` (既存ローカル値のコピー) | 出所未追跡 (**追跡不可**) |
| `0x1007c91d4` | `(x25, 1216)` | `ldr w8,[x25,#0x4b8]` (既存ローカル値のコピー) | 出所未追跡 (**追跡不可**) |
| `0x1007ca1a8` | `(x25, 1760)` | `ldr w8,[x25,#0x680]` (既存ローカル値のコピー、直前に `cbz`/内側 CFF あり) | 出所未追跡 (**追跡不可**) |
**6 件は「既存のローカル変数コピー/算術結果」であり、その値の一次的な出所 (元をたどれば sysctl の
結果か、単なるループカウンタか) は今回の後方追跡ウィンドウ (直近 6〜10 命令) では確定できなかった**。
これ以上追うには、各 slot ごとに専用のデータフロー解析 (SSA 構築や symbolic execution) が必要であり、
本セッションのスコープでは**未確定・追跡不可のまま報告する**。
### 4-3. anti-debug syscall との対応関係 (ゾーン重複のため確定的な帰属はできない)
`docs/spec/znca_ios_init_gen_audio_h_static.md` §4-2 に記載の 3 箇所の生 `kill` (SVC) 呼び出しが、
どの state slot ゾーンに属するかを機械的に確認した:
| kill site | 属するゾーン |
|---|---|
| `0x1007c9b14` | `x12`, `x14`, `x19`, `x25` |
| `0x1007cc560` | `x12`, `x13`, `x14`, `x19` |
| `0x1007cdd44` | `x12`, `x13`, `x14`, `x19`, `x22` |
いずれも 4〜5 個のゾーンに**同時に**属しており (`x19` はほぼ全域をカバーするため常にヒットする)、
ゾーンによる疑似関数分割では anti-debug 処理を単一の疑似関数に切り分けることはできなかった。
**確定的な帰属付けは不可能** — これは §1 で述べた通り「少数の共有 state 変数」という前提が
成立しないことの直接的な帰結である。
---
## 5. `pthread_create` の thread routine — 特定成功
前セッションでは「CFF に埋もれて線形解析では復元不可」と判定されていたが、**実際には CFF は
一切絡んでおらず、単純な `adrp`+`add` で直接特定できた**。
```
0x1007a4128: adrp x8, #0x100a41000
0x1007a412c: add x2, x8, #0x50 ; x2 = 0x100a41050 (start_routine)
0x1007a4130: add x0, x19, #5, lsl #12
0x1007a4134: add x0, x0, #0x318 ; x0 = &thread (out param)
0x1007a4138: mov x1, #0 ; x1 = NULL (attr)
0x1007a413c: add x3, x19, #0x10, lsl #12
0x1007a4140: add x3, x3, #0x7d7 ; x3 = arg
0x1007a4144: bl #0x100c347d0 ; pthread_create(x0, x1, x2, x3)
```
**thread routine VA = `0x100a41050`**。`LC_FUNCTION_STARTS` で正式な関数境界として確認済み
(直前の関数開始 `0x100a40dfc`、直後の関数開始 `0x100a4129c` — つまりサイズ `0x24c` バイト)。
この関数自体も**独自の CFF (own dispatch idiom, own local state slot `[sp,#0x30]` 系列) を持つ**
別関数であり、`initGenAudioH` 本体とは完全に独立している。内部で呼ぶ import は 3 つ:
| VA | シンボル (rabin2 `-i` で解決) |
|---|---|
| `0x100c330a8` | `CFStringGetCString` |
| `0x100c33414` | `NSSearchPathForDirectoriesInDomains` |
| `0x100c32fdc` | `CFArrayGetValueAtIndex` |
ループ構造 (`cbnz w8, #0x100a410b0` による戻り分岐) と合わせると、**この thread は
`NSSearchPathForDirectoriesInDomains` で取得したパス配列を `CFArrayGetValueAtIndex` で順に取り出し、
各要素を `CFStringGetCString` で C 文字列化するループ**であると判断できる。これは前回ドキュメントが
「whitebox テーブル展開/検証を別スレッドで行っている」と推測していた内容よりも具体的で、
**標準 iOS 検索パス (Documents/Library/Caches/tmp 等) を辿ってファイルシステム上の
ジェイルブレイク痕跡/改ざんアーティファクトを探索するバックグラウンドスレッド**という解釈のほうが
証拠と整合する (呼び出し順序は CFF により実行時に決まるため確定はできないが、3 API の組み合わせ自体が
強い状況証拠)。
---
## 6. ホワイトボックステーブル領域への読み書き (task #5)
`docs/spec/znca_ios_init_gen_audio_h_static.md` は「`0x100f70000`–`0x101192000` (約2.13MiB) の
高エントロピー領域が TFIT 系ホワイトボックス表」「`initGenAudioH` はこれを *読み込まない*」と推定していた。
今回、ADRP が指すレジスタを実際に**後続命令の base オペランドとして参照しているか (真の deref)** を
区別する厳密なチェックを行い、以下を確認した:
```
genuine DEREF reads (ldr [wb_addr]): 498 件
genuine DEREF writes (str [wb_addr]): 0 件 ← ホワイトボックス領域への書き込みは皆無
pointer-cached (アドレス値をスタックへ退避するだけ、非 deref): 65 件
```
`0x101034204` を含む「決め打ちの単一ページ」ではなく、**ホワイトボックス範囲内の少なくとも 68 個の
異なる 4KiB ページ**に散らばって読み出しが発生している。単一のテーブル参照ではなく広範囲を
まんべんなく読む挙動は、**AES ホワイトボックス演算そのものではなく、テーブル全体のチェックサム/
ハッシュを計算する改ざん検知 (integrity check) パス**である可能性が高い
(実際の AES 演算テーブル参照は `docs/spec/znca_ios_init_gen_audio_h_static.md` §5 の通り
`_gen_audio_h`/`_gen_audio_h2` (`0x10090f20c`/`0x1007f425c`) 側で行われ、`initGenAudioH` からは
呼ばれない)。
**結論**: task #5 で問われた「ホワイトボックス state の書き換え箇所」は **`initGenAudioH` 内には
存在しない** (書き込み 0 件を確認済み)。ホワイトボックス由来の値を使った"合成"らしき処理は
§4-2 の `0x1007caacc`/`0x1007cab1c`/`0x1007d4d48` (ローリング XOR) に見られるが、これは
**スタックローカルへの書き込みであり、マップ済みテーブル自体を書き換えるものではない**。
---
## 7. まとめ表
| 項目 | 結論 |
|---|---|
| CFF ディスパッチの数式 | `target = target_base + state` (`a^b + 2*(a&b) = a+b` の恒等式による難読化)。Type A (間接、317件) / Type B (直接、377件)、計 694 件 |
| **CFF state 変数の実体** | **前回推定 (`0x101034204` 他グローバル) は誤り。実際は関数プロローグで確保した専用スタックスロット (693 個、ほぼ 1 site 1 slot)** |
| `0x101034204` | 書き込み専用の囮シンク (629 writes / 0 reads)。CFF とは無関係 |
| `0x10113a520` 等 5 種 | 読み出し優勢のデータ/設定値。CFF state ではない |
| de-flatten 成功率 | state writer 714 件中 701 件 (98.2%) を定数解決、うち 691 件 (98.6%) が妥当な (整列・範囲内) ターゲット |
| **実際に意味のある分岐** | de-flatten された 693 件のうち **わずか 15 件** (非ゼロ定数 2 + 動的 13)。残り 678 件は state=0 による no-op (フォールスルーの迂回路)。**CFF の 99.7% は静的解析妨害だけが目的の opaque predicate** |
| anti-inject 検知の直接証拠 | `0x1007c8bf8`: `_dyld_image_count()` の戻り値が **そのまま** CFF 分岐セレクタに使われている |
| anti-debug syscall の疑似関数帰属 | ゾーン重複のため確定不可 (`kill` 3 箇所いずれも 4〜5 ゾーンにまたがる) |
| **`pthread_create` thread routine** | **`0x100a41050`** (LC_FUNCTION_STARTS 確認済み, size `0x24c`)。CFF ではなく単純な `adrp+add` で特定。`NSSearchPathForDirectoriesInDomains`+`CFArrayGetValueAtIndex`+`CFStringGetCString` を呼ぶループ = 標準検索パスのファイル列挙 (ジェイルブレイク痕跡探索の可能性) |
| ホワイトボックス領域への書き込み | **0 件** (498 件の読み出しのみ、68+ ページに分散 → 改ざん検知目的の走査と推定) |
| pseudo-CFG 規模 | leaders 13200 / CFF エッジ 693 / 直接分岐 10150 / bl 273 |
## 8. 未確定・追跡不可のまま残した項目
- 4-2 節の 13 件中 6 件 (`0x1007bba7c`, `0x1007bcb50`, `0x1007bd1f4`, `0x1007c7f90`, `0x1007c8490`,
`0x1007c91d4`, `0x1007ca1a8` — 実際は 7 件) は、値が既存ローカル変数のコピー/単純算術結果であり、
その一次的な出所 (sysctl の戻り値か、単なるカウンタか) を後方追跡ウィンドウ内で確定できなかった。
各 slot 専用のデータフロー解析が必要。
- `sel_registerName` に渡されたセレクタ名文字列 (`0x1007bcae8`/`0x1007bdbf0` の呼び出し引数 `x0`) は
未解決。CFF に埋もれた文字列構築ロジックの追跡が必要。
- `docs/spec/znca_ios_init_gen_audio_h_static.md` §4 に記載の `sysctl` MIB 具体値・`kill` の signal 番号・
dyld image walk の比較文字列は、本セッションのスコープ (CFF de-flatten) では解決していない
(別タスク、文字列/定数デコーダの特定が前提)。
- 疑似関数への「明確な分割」は達成できなかった (§1, §4-3 参照)。ゾーンベースの `cfg_zone_*.dot` は
参考程度に留め、確定的な境界としては扱わないこと。
## 9. 成果物一覧
- `targets/znca_ios_3.4.1/analysis/cff/common.py`
- `targets/znca_ios_3.4.1/analysis/cff/disasm_body.py` (+ `insns.pkl` キャッシュ)
- `targets/znca_ios_3.4.1/analysis/cff/explore_wrapper.py`
- `targets/znca_ios_3.4.1/analysis/cff/find_dispatch.py`
- `targets/znca_ios_3.4.1/analysis/cff/resolve_edges.py`
- `targets/znca_ios_3.4.1/analysis/cff/build_cfg.py` (+ `cfg.pkl`)
- `targets/znca_ios_3.4.1/analysis/cff/export_dot.py` (+ `cfg_full.dot`, `cfg_zone_{x8,x12,x13,x14,x15,x19,x20,x22,x25,x26,x28}.dot`)
- `targets/znca_ios_3.4.1/analysis/cff/list_real_effects.py`
- 本ドキュメント: `docs/spec/znca_ios_init_gen_audio_h_cff.md`
@@ -0,0 +1,441 @@
# znca (Nintendo Switch Online) iOS 3.4.1 — `+[VoIPClient initGenAudioH]` 静的解析
対象: `/home/vscode/app/targets/znca_ios_3.4.1/Crew`
Mach-O arm64, `__TEXT` vmaddr = `0x100000000`。ASLR 補正不要のイメージベース相対で全アドレス報告する。
補助スクリプトは `/tmp/parse_voipclient.py`, `/tmp/scan_func.py`, `/tmp/categorize_refs.py`, `/tmp/check_antidbg.py`, `/tmp/check_svc.py`, `/tmp/find_bl.py`, `/tmp/find_bls_to_local.py`, `/tmp/adrp_scan.py` に格納。
生成物のうち `parse_voipclient.py` のみリポジトリに `targets/znca_ios_3.4.1/analysis/parse_voipclient.py` として残す。
## 訂正履歴
本ドキュメント初出後、以下 3 本の後続解析ドキュメントにより一部の結論が覆された/精密化された。
該当箇所には「(訂正: 2026-07-03、詳細は ...)」の脚注を付けてある。
- [`znca_ios_whitebox_layout.md`](./znca_ios_whitebox_layout.md) — whitebox テーブル領域の内部レイアウト深堀り (pure-table 82.6% / CFF-plumbing 17.4% 分離、chained-fixups 解析)
- [`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md) — initGenAudioH 本体の CFF de-flatten (真の state slot 特定、CFF の 99.7% が opaque predicate/no-op と判明)
- [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) — アンチデバッグ文字列/定数の精密化 (sysctl 10 回・getpid 4 回・kill sig=0 の確定)
---
## 1. `+[VoIPClient initGenAudioH]` 実装アドレス
`__DATA_CONST.__objc_classlist` から `VoIPClient` メタクラスを resolve し、`class_ro_t.baseMethods`(relative method list形式) をパースした結果。
- `VoIPClient` class object VA: `0x100f3e468`
- `VoIPClient` metaclass VA: `0x100f3e418`
- metaclass `data` VA (class_ro_t): `0x100f0a8e8`
- metaclass `baseMethods` VA: `0x100c5c0e8` (relative_method_list_t, entsize=0xc, count=8)
メタクラス (`+`) メソッド一覧:
| Idx | Selector | IMP | Types |
|---|---|---|---|
| 0 | `srtpInit` | `0x1006f444c` | `v16@0:8` |
| 1 | `ignoreSigPipe` | `0x100a2a8f8` | `v16@0:8` |
| 2 | `srtpShutdown` | `0x100775f7c` | `v16@0:8` |
| 3 | **`initGenAudioH`** | **`0x100a29528`** | `v16@0:8` |
| 4 | `genAudioH:i2:i3:` | `0x10072b634` | `@40@0:8@16@24@32` |
| 5 | `genAudioH2:i2:i3:` | `0x100708a3c` | `@40@0:8@16@24@32` |
| 6 | `encryptRequest:appVersion:url:accessToken:` | `0x10072cc00` | ... |
| 7 | `decryptResponse:` | `0x1007da0b4` | ... |
したがって **`+[VoIPClient initGenAudioH]` の実 IMP は VA `0x100a29528` (RVA `0xa29528`)**。
サイズは `LC_FUNCTION_STARTS` から `0x7c` バイトのごく小さなラッパー。
### ラッパー→本体の関係
`0x100a29528` のディスアセンブリ:
```
0x100a29528: sub sp, sp, #0x20
0x100a2952c: stp x29, x30, [sp,#0x10]
0x100a29530: add x29, sp, #0x10
0x100a29534: str xzr, [sp,#8] ; state = 0
0x100a29538: ldr x11, [sp,#8] ; --- CFF ディスパッチ開始 ---
0x100a2953c: adr x8, 0x100a29560 ; state table base
0x100a29540: ldrsw x9, [x8]
0x100a29544: add x8, x9, x8
0x100a29548: and x9, x8, x11
0x100a2954c: movz x10, #2
0x100a29550: mul x9, x9, x10
0x100a29554: eor x8, x8, x11
0x100a29558: add x8, x8, x9
0x100a2955c: br x8
0x100a29560: .word 4 ; state=0 → next dispatch
0x100a29564: movz w8, #3
0x100a29568: str w8, [sp]
0x100a2956c: b 0x100a29588
...
0x100a2957c: bl 0x100783fc8 ; ← 本体呼び出し
0x100a29580: movz w8, #4
0x100a29584: str w8, [sp]
```
つまり `+[VoIPClient initGenAudioH]` は control-flow flattening 化された 3 状態の tiny スタブで、
中身は **`sym.func.100783fc8` (LC_FUNCTION_STARTS 上 `0x100783fc8` .. `0x1007d594c`、サイズ `0x51984` = 約 335 KiB)** に丸ごと入っている。
- `bl 0x100783fc8` の唯一のコールサイトは `0x100a2957c` (バイナリ全域から検索済み) → **`0x100783fc8` は事実上 `initGenAudioH` の本体そのもの**。
以降「initGenAudioH 本体」は `0x100783fc8` を指す。
---
## 2. 本体関数 `0x100783fc8` の逆コンパイル所見
### 構造
- 単一の巨大 basic block (radare2 の初期解析では `num-bbs: 1`)。プロローグ 1 発、エピローグ 1 発 (`ldp x29,x30,[sp,#0x50]; add sp,sp,#0x2270; ret`)。
- スタックフレーム約 **90 KiB** (`sub sp, sp, #0x16, lsl 12; sub sp, sp, #0x7f0` = 0x167f0 バイト)、実行時ワーキングエリアはこのフレーム内。
- OLLVM 系 control-flow flattening + opaque predicate + 隣接コード生成 (`adr x9, next_bb; sub/add x9,x9,x11` パターン) の重ね掛け。
- ディスパッチ変数は **`__DATA.__data:0x101034204` (int32)** に格納 (関数内の `adrp+add` 出現 628 回、その大半がこの state 変数の read/write)。初期値 `0xffffffff`。
**(訂正: 2026-07-03、詳細は [`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md) §1)**: `0x101034204` は CFF dispatch 変数ではなく **write-only の囮シンク (629 writes / 0 reads)** だった。真の CFF state は関数プロローグで確保された **693 個の private stack slot** であり、9 個のゾーンベースレジスタ (`x8`/`x12`/`x13`/`x14`/`x15`/`x19`/`x20`/`x22`/`x25`/`x26`/`x28`、実測では 11 種) 経由でアクセスされる。1 サイト 1 専用スロットに近く、少数の共有 state 変数という前提は成立しない。
- 内部の疑似 basic block 境界を跨いだレジスタ流れは、CFF のため線形トレースでは追えない (パラメータ復元は諦めた)。
### 呼び出し対象
| 種類 | 目的の代表例 |
|---|---|
| Import stub (`__TEXT.__stubs`) | libc/libSystem/libdyld 38 種、計 215 回 |
| 内部関数 BL | 13 個の内部関数 |
| BLR (`br` によるテーブルディスパッチ) | 16 箇所 |
| SVC #0x80 (raw syscall) | 35 箇所 |
| BRK | 2 箇所 (`0x1007d98b0`, `0x1007d9a68`) |
### インポート経由の呼び出し (`__TEXT.__stubs` 分解)
| ストブ VA | シンボル | 本体からのコール回数 |
|---|---|---|
| `0x100c32fe8` | `CFCopyHomeDirectoryURL` | 2 |
| `0x100c33084` | `CFRelease` | 4 |
| `0x100c330a8` | `CFStringGetCString` | 2 |
| `0x100c330b4` | `CFURLCopyFileSystemPath` | 2 |
| `0x100c33648` | `_Unwind_Resume` | 1 |
| `0x100c3390c` | `std::__1::recursive_mutex::lock` | 6 |
| `0x100c33918` | `std::__1::recursive_mutex::unlock` | 12 |
| `0x100c3396c` | `std::__1::mutex::lock` | 19 |
| `0x100c33978` | `std::__1::mutex::unlock` | 36 |
| `0x100c33ad4` | `operator delete(void*)` | 8 |
| `0x100c33af8` | `operator new[](size_t)` | 2 |
| `0x100c33b04` | `operator new(size_t)` | 8 |
| `0x100c33bc4` | `__error` (errno) | 41 |
| `0x100c33c24` | `__snprintf_chk` | 3 |
| `0x100c33c30` | `__stack_chk_fail` | 1 |
| `0x100c33c60` | `_dyld_get_image_header` | 2 |
| `0x100c33c6c` | `_dyld_get_image_name` | 3 |
| `0x100c33c78` | `_dyld_get_image_vmaddr_slide` | 2 |
| `0x100c33c84` | `_dyld_image_count` | 2 |
| `0x100c33e04` | `clock_gettime` | 2 |
| `0x100c33e1c` | `closedir` | 1 |
| `0x100c33ff0` | `dladdr` | 1 |
| `0x100c33ffc` | `dlclose` | 1 |
| `0x100c34008` | `dlopen` | 1 |
| `0x100c34014` | `dlsym` | 1 |
| `0x100c34110` | `getenv` | 1 |
| `0x100c3426c` | `malloc` | 3 |
| `0x100c342b4` | `memset` | 4 |
| `0x100c343f8` | `objc_getClass` | 6 |
| `0x100c34434` | `objc_msgSend` | 12 |
| `0x100c346d4` | `opendir` | 1 |
| `0x100c347d0` | `pthread_create` | 1 |
| `0x100c3480c` | `pthread_join` | 1 |
| `0x100c34920` | `rand` | 4 |
| `0x100c34944` | `readdir` | 1 |
| `0x100c34980` | `sel_registerName` | 16 |
| `0x100c34b84` | `sranddev` | 1 |
| `0x100c353dc` | `uuid_generate` | 1 |
読み解けるふるまい:
- **`opendir` + `readdir` + `closedir`** … ファイルシステムを列挙。jailbreak 検出用のディレクトリ walk (Cydia/Sileo/frida-server 等の探索) と推定される。文字列は文字化けせず存在するが、`__cstring` セクションからは "frida"/"cydia"/"substrate" 系リテラルは見つからなかった (難読化/XOR 済みの可能性が高い。ステート機に混ぜて decode 後に比較していると思われる)。
- **`_dyld_image_count` + `_dyld_get_image_name` + `_dyld_get_image_header` + `_dyld_get_image_vmaddr_slide` + `dladdr`** … 読み込み中の image を全部舐めているので、注入 dylib (frida-agent, substrate) の検出目的とみて確度高。
- **`dlopen` + `dlsym` + `getenv`** … 環境変数を見て特定関数を dynamic resolve。`DYLD_INSERT_LIBRARIES` チェックの可能性が高い (直接 `getenv("DYLD_INSERT_LIBRARIES")` かどうかは第一引数の string リテラルが CFF に埋もれて linear tracker では拾えなかった。**未確定**)。
- **`pthread_create` (1回) + `pthread_join` (1回)** … 別スレッドで長時間タスク (whitebox テーブル展開/検証) を回している。thread_start は CFF に埋もれた `x2` セットアップに依存しており線形解析では復元できず。
- **`sranddev` + `rand ×4`** … 実行時ランダム化。エントロピーとしてホワイトボックス状態のブラインディング/デコイに使っている可能性がある。
- **`uuid_generate`** … デバイス識別 or セッション salt 用の乱数 UUID。
- **`objc_getClass` ×6, `objc_msgSend` ×12, `sel_registerName` ×16** … CFCLassRef 経由で `NSFileManager` / `NSString` / `NSProcessInfo` 系のクラスをランタイム解決している気配 (直接名は CFF 内で難読化されており未特定)。
- **`clock_gettime` ×2** … 時間ベースの anti-debug (frida hook で BL が遅くなる差分で検出する典型パターン)。
- **std::mutex / recursive_mutex を大量使用** … 実装は C++ で、内部ステート machine を mutex で守っている。共通する mutex は 4〜5 個。
### 生 SVC 呼び出し (35 箇所)
各 SVC 直前の `movz w16, #imm` から特定した syscall 番号。
| syscall | 名前 | 回数 | 代表位置 |
|---|---|---|---|
| 4 | `write` | 5 | `0x1007a26d0` … |
| 5 | `open` | 1 | `0x1007a20b8` |
| 6 | `close` | 1 | `0x1007a4a68` |
| 10 | `unlink` | 7 | `0x10079fb40` … |
| 33 | `access` | 3 | `0x10079fd38` / `0x1007a5f0c` / `0x1007ab8b8` |
| 37 | **`kill`** | 3 | `0x1007c9b14` / `0x1007cc560` / `0x1007cdd44` |
| 57 | `symlink` | 2 | `0x10079eb20` / `0x1007ae4e0` |
| 202 | **`__sysctl`** | 12 | `0x1007c499c` / `0x1007ca000` / … |
| 不明 | (movz 検出できず) | 4 | `0x1007c4824`, `0x1007c9a74`, `0x1007ccf64`, `0x1007cdce4` |
**(訂正: 2026-07-03、詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §2 §8)**: `__sysctl` は **12 回ではなく 10 回**。「movz 検出できず」としていた 4 箇所 (`0x1007c4824`, `0x1007c9a74`, `0x1007ccf64`, `0x1007cdce4`) は `mov w8,#0x14 → mov w16,w8` というレジスタ間接で x16 をセットしていたため静的スキャンで拾えなかっただけで、実体は **raw `getpid()` (SVC 20)** だった。sysctl 10 回の MIB は全て `{CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1, pid}` で、9/10 サイトで動的エミュレーションにより実測確認済み (P_TRACED 検出パターン)。
`__sysctl` × 10 と `kill` × 3 の同居は、明白な **anti-debug** の指紋。libc の `sysctl()` を通さず `svc #0x80` 直呼び出しにしているのは、Frida/Substrate がラッパー関数を hook しても bypass できるようにするための古典的テクニック。ただし `kill` については **self-kill 実装ではないと判明した** (詳細は §4-2 の訂正脚注参照)。
### 内部関数 BL
`0x100783fc8` の中で BL される内部関数 (import stub でない BL) は 13 種。
| 呼び先 | サイズ | 呼び出し回数 | 備考 |
|---|---|---|---|
| `0x1007df904` | `0xb8` | 19 | mutex ペア + `[x19+0x18]` の 2bit フィールド操作 → 小さな **thread-safe counter/flag getter** |
| `0x100a3325c` | `0x38` | 17 | 単なる `b 0x1007794d4` (テールコール via CFF) — 実質 `0x1007794d4` のラッパー |
| `0x1007d594c` | `0x454` | 5 | `malloc` を使って `0x1013d5*` 領域 (`__DATA.__common`) にオブジェクトを詰めるコンストラクタ様。ここが唯一 initGenAudioH 本体の"直後"に位置する compound-init |
| `0x10076fe7c` | `0x620` | 4 | 内部で `0x1007d594c` を呼び出す。dispatch/factory 系 |
| `0x100763280` | `0x16c` | 2 | 未解析 |
| `0x1007773d4` | `0x2a0` | 2 | 未解析 |
| `0x1006f65a8` | `0x170` | 2 | 未解析 |
| `0x100775838` | `0x60` | 2 | 未解析 |
| `0x100a2a0fc` | `0x1c0` | 1 | 未解析 |
| `0x100a3ad40` | `0x114` | 1 | 未解析 |
| `0x100768eec` | `0x1660` | 1 | 大型ユーティリティ |
| `0x1007794d4` | `0x21c` | 1 | 未解析 |
| `0x1006edadc` | `0x50` | 1 | 未解析 |
`_gen_audio_h` / `_gen_audio_h2` / `_set_platform_for_gen_audio` は **initGenAudioH 本体からは直接呼ばれない**。initGenAudioH は「ホワイトボックスの状態を `__DATA.__data` に構築」するだけで、実際の音声偽装処理はランタイムに `+[VoIPClient genAudioH:...]` から呼ばれる。
---
## 3. rodata 読み出しマッピング (全 data references)
`0x100783fc8` .. `0x1007d594c` の範囲を capstone で線形走査し、ADRP+ADD/LDR ペアを追跡した結果 (`/tmp/categorize_refs.py` の出力)。
- 総 distinct target: **309 種**
- 総 data reference 命令: **1591 回**
セクション別内訳:
| セクション | distinct targets | refs | コメント |
|---|---|---|---|
| `__TEXT.__text` | 1 | 1 | `0x100a41001` — スタブ or CFF opaque address |
| `__DATA_CONST.__got` | 2 | 3 | `0x100e57350` (`objc_alloc` reloc), `0x100e575a0` |
| `__DATA_CONST.__const` | 1 | 2 | `0x100eb39e8` |
| **`__DATA.__data`** | **265** | **1376** | ホワイトボックスワーキング領域書き込みと state machine |
| `__DATA.__bss` | 29 | 128 | 内部フラグ・カウンター群 (`0x1013cfb84`..`0x1013cfd00` 付近に密集) |
| `__DATA.__common` | 11 | 81 | `0x1013d5d28`..`0x1013d5dd8` 付近の C++ オブジェクトスロット |
- **`__DATA_CONST` はほぼ触っていない (計 5 refs のみ)**。これは重要:「ソース側のホワイトボックステーブルは静的にリンクされていない」ことを示唆する。テーブル本体は `__DATA.__data` 内に平文で置いてあり、`initGenAudioH` は変換/コピー/展開ではなくむしろ **ステートマシン初期化+一部フィールドのシードのみ** を行う (詳細は後述)。
- ホットな target 上位 15 (すべて `__DATA.__data`):
- `0x101034204` (628x) … **CFF ディスパッチ用 state 変数** (int32、初期値 `0xffffffff`)。ホワイトボックス表ではない。
- `0x10113a520` (108x) … 未確認
- `0x10103d998` (24x) / `0x10103f9e0` (20x) / `0x101084300` (16x) / `0x10110e020` (16x) … CFF state 変数の亜種と思われる (それぞれ別の疑似関数のディスパッチキー)。
- `0x10113a408` (12x), `0x10110f1f0` (12x), `0x1010426e0` (12x), `0x10111c630` (12x), `0x101041cd0` (12x), `0x10103faa8` (12x) … 同上
**(訂正: 2026-07-03)**: 上記の解釈は誤り。`0x101034204` は CFF dispatch 変数ではなく write-only decoy sink (詳細は §2 の脚注、出典 [`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md))。それ以外の 11 個のホットアドレスも「CFF state 変数の亜種」ではなく、[`znca_ios_whitebox_layout.md`](./znca_ios_whitebox_layout.md) §2.3 の LIEF chained-fixups 解析により **rebase 済みポインタスロット (11/12 が一致)** と判明した。OLLVM CFF の一部実装は次の基本ブロックのアドレスを整数キーではなく「rebase 済みポインタ変数」として保持し `br xN` で直接ジャンプするため、PIE バイナリではロード時に Mach-O ローダの chained-fixups の対象になる。これが `__DATA.__data` の同一 VA レンジに暗号定数テーブルと CFF 用間接分岐ポインタが混在していた理由。
- `__DATA.__bss` トップ:
- `0x1013cfcac`, `0x1011cdc74`, `0x1013cfce0`, `0x1011cdc80` (それぞれ 6 refs)。これは "初期化フラグ" のセットで、`+[VoIPClient initGenAudioH]` が `dispatch_once` 相当を hand-rolled で実装している気配。
- `__DATA.__common`:
- `0x1013d5da8`, `0x1013d5dc0`, `0x1013d5db8`, `0x1013d5d28` (それぞれ 11 refs)。`0x1013d5*` 領域には C++ オブジェクトスロットが `malloc(8)` で 4 つ確保される (`0x1007d594c` 内で観測)。
### なぜ `__DATA_CONST.__const` は空か
`initGenAudioH` が読み込むホワイトボックスの巨大定数を静的解析で先に押さえるため、`__DATA` セグメント全体を 64 KiB 窓 shannon エントロピーで走査した:
```
0x100f42000: 1.86 <- __DATA.__data head (低エントロピー: ポインタ表)
0x100f52000: 2.05
0x100f62000: 5.40 <- 中エントロピー (混合)
0x100f72000: 7.89 <- 高エントロピー開始 (乱数/暗号)
0x100f82000..0x101182000: 7.85 .. 8.00 <- 一貫して高エントロピー
0x101192000: 4.81 <- 低エントロピー (bss 直前 / 平文構造体)
```
- **高エントロピーの塊は `~0x100f70000`〜`~0x101190000`、実サイズ約 `0x220000` バイト ≒ 2.13 MiB**。
- ここは Mach-O の `__DATA.__data` にべた書きされた **静的ホワイトボックステーブル群** で、`initGenAudioH` が「ロードする」対象ではなく、**ロードするまでもなく既にプロセスの virtual address に mapped 済み** (Mach-O ローダが実施)。
- したがって「`initGenAudioH` が rodata から key/table を dump する」のではなく、`initGenAudioH` の役割は **(a) anti-debug チェック → 通れば (b) スレッド起動と mutex 系の初期化 → (c) 一部 seed 値の rand / uuid_generate 埋め込み** に近い。
### ホワイトボックステーブル領域の詳細
64 KiB 窓走査結果:
| VA 範囲 | 概念 |
|---|---|
| `0x100f42000` .. `0x100f52000` | ポインタ表 / vtable (低エントロピー) |
| `0x100f62000` .. `0x100f72000` | 遷移領域 (中エントロピー、C 構造体らしい) |
| **`0x100f70000` .. `0x101192000`** | **ホワイトボックス T-box / S-box 群 (エントロピー 7.85〜8.00)** |
| `0x101192000` .. `0x101197c40` | 遷移 (エントロピー 4.81) |
2.13 MiB 弱の高エントロピー = 典型的な TFIT/Chow スタイル AES ホワイトボックスの規模感 (ラウンド × ステート × テーブル数 で ~1〜2 MB は妥当)。
- 内部に **256 バイトの AES SBox** が置かれているか、簡易走査:
- SBox の先頭 4 バイトは `63 7c 77 7b` (AES SBox)。バイナリ全体を検索した結果、この完全パターンは見つからず → `initGenAudioH` は素の AES SBox を露出させておらず、ホワイトボックス化されたテーブルのみが存在すると判断できる。
- MixColumns/InvMixColumns 定数もヒットしない。全ラウンド定数がテーブルに事前吸収されている、または各エントリが XOR マスクで難読化されていると推定される。
- **推定される全ホワイトボックス範囲**: `0x100f70000` .. `0x101192000` (約 2.13 MiB)。ここが `_gen_audio_h` / `_gen_audio_h2` 実行時に参照される TFIT 表。`initGenAudioH` はこれをロードせず、Mach-O が map したものを "unlock" しているだけ (typical: 各ロード後にランタイムマスクを付け足す)。
---
## 4. アンチデバッグの実装位置
以下は `initGenAudioH` 本体 (`0x100783fc8` .. `0x1007d594c`) 内で発見した具体的な検出手段。
### 4-1. 生 `sysctl` 10 回 (SVC #0x80, x16=0xca)
**(訂正: 2026-07-03、詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §2)**: 旧「12 回」は誤り。実際は **10 回**であり、区別できていなかった残り 4 箇所は `getpid` (SVC 20) だった。
代表位置: `0x1007c499c`, `0x1007ca000`, `0x1007ca4fc`, `0x1007ca6ec`, `0x1007cca8c`, `0x1007ccd3c`, `0x1007cd17c`, `0x1007cdfc0`, `0x1007ce200`, `0x1007ce8d8`。
典型的パターン (`0x1007c4980`〜):
```
mov x0, x8 ; MIB pointer
mov w1, w9 ; MIB length
mov x2, x10 ; oldp
mov x3, x11 ; oldlenp
mov x4, x12 ; newp
mov x5, x13 ; newlen
movz x16, #0xca ; __sysctl
svc #0x80
b.lo skip_neg
mov w1, #-1
mul w0, w1, w0 ; negate errno
mov w8, w0
skip_neg:
str w8, [x28, #0xc74] ; result to state
```
**用途**: MIB = `{CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()}` として `struct kinfo_proc` を取り、`kp_proc.p_flag & P_TRACED` を確認する最典型的 anti-debug。12 回もあるのは、複数の MIB (KERN_PROC / KERN_PROC_ALL / HW_MACHINE / etc) をローテーションで確認しているためと推定 (CFF に埋もれて MIB 定数を復元できず未確定。**MIB の具体値は未確定**)。
**(訂正: 2026-07-03、詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §1 §2)**: 回数は 12 ではなく **10**。MIB は Unicorn Engine による動的エミュレーションで実測され、**`{CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1, pid}` で確定**した (9/10 サイトで一致、残り 1 サイトはエミュレーションで未到達)。「MIB の具体値は未確定」は解消済み。ただし pid フィールドの実測値は常に `0` で、これはエミュレーション環境のアーティファクトの疑いが強く実機の pid 値そのものは依然未確定。
### 4-2. 生 `kill` 3 回 (SVC #0x80, x16=0x25)
- `0x1007c9b14`
- `0x1007cc560`
- `0x1007cdd44`
前後のコンテキストは全て同じ:
```
mov w0, w8 ; pid
mov w1, w9 ; signal
movz x16, #0x25 ; kill
svc #0x80
b.lo skip_neg
mov w1, #-1
mul w0, w1, w0
```
→ 検出時の self-terminate 実装。libc の `abort()` / `exit()` を使わないので、Frida の `Interceptor.replace(abort, () => {})` が効かない。**この 3 箇所こそが「Frida hook で強制クラッシュ」の実行地点**。`kill(getpid(), SIGKILL)` あるいは `kill(getpid(), SIGABRT)` と推定される (どの signal かは CFF で追い切れず **signal 番号は未確定**、ただし `movz w9, #imm` の状態を近隣で観測すると `w9=0x9 (SIGKILL)` の可能性が高い候補が 1 つあった)。
**(訂正: 2026-07-03、詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §3)**: 上記の推定は誤りだった。3 箇所とも **`sig=0` で固定**であることが静的 (`svc` 直前に `str wzr, [slot]` で明示的にゼロクリア) と動的 (Unicorn エミュレーション) の両方で確認済み。`kill(pid, 0)` は POSIX 上「シグナルを送らずプロセスの生存確認だけを行う」**良性の liveness probe** であり、**self-kill 経路ではない**。したがって「Frida hook で強制クラッシュ」の実行地点はこの 3 箇所ではなく、initGenAudioH の外にある別経路と考えられる (未確定、別調査中)。旧来観測されていた Frida hook クラッシュ (500 回再帰 `objc_msgSend`) の実行地点は本関数の解析範囲外。
### 4-3. dyld image walk による inject dylib 検出
- `_dyld_image_count` @ `0x1007c23f0`, `0x1007c8bf4`
- `_dyld_get_image_name` @ `0x1007a7858`, `0x1007a8c90`, `0x1007c89c0`
- `_dyld_get_image_header` @ `0x1007c0c80`, `0x1007c71f0`
- `_dyld_get_image_vmaddr_slide` @ `0x1007c8a1c`, `0x1007c8ac0`
- `dladdr` @ `0x1007c199c`
パターン: `image_count → 各 image で name/header/slide を取得 → 名前を obfuscated string と `strcmp` (推定)`。`substrate`, `frida`, `MobileSubstrate`, `SubstrateBootstrap` 等の比較先文字列は `__cstring` に平文では見つからず (`strings` で検出できない) → 実行時 XOR/rotate デコードと推定。**比較文字列の平文値は未確定**。
### 4-4. dlopen / dlsym / getenv 検査
- `getenv` @ `0x1007c3c5c` — 第一引数の string リテラルが CFF で追えず未確定。**推定: `getenv("DYLD_INSERT_LIBRARIES")`**。
- `dlopen` @ `0x1007a4b98` — 引数文字列は同じく CFF に埋もれて未確定。
- `dlsym` @ `0x1007c3f30` — x0 = `-1` (`movn x0, #1` = `RTLD_NEXT`) が直前に見えた。Frida が inject するランタイム関数 (例えば `MSHookFunction` / `objc_msgSendSuper2` / `substrate_*`) の存在を dlsym で探して非 NULL なら Substrate 環境と判定するパターン。
### 4-5. ファイルシステム jailbreak 探索
- `opendir` @ 単一, `readdir` @ 単一, `closedir` @ 単一, `access` (SVC) × 3, `unlink` (SVC) × 7。
- `/Applications/Cydia.app`, `/private/var/lib/apt`, `/usr/libexec/cydia`, `/etc/apt`, `/Library/MobileSubstrate/MobileSubstrate.dylib`, `/var/log/apt`, `/private/var/cache/apt/` などを列挙 or `access(2)` するのが典型。文字列は難読化されており平文検出できず (**該当リテラルは未確定**)。`symlink` (57) が 2 回あるのは Rocky-mountain 検出テク (シンボリックリンクを一度作って読み戻しで sandbox 動作を検証、または JB 検出そのもの) の可能性。
### 4-6. 検出→クラッシュ経路まとめ
1. `sysctl` / dyld walk / `dlsym` / opendir で異常検出。
2. state 変数 (`__DATA.__data:0x101034204` を含む複数) を「異常」値にセット。
3. CFF ディスパッチが「異常パス」へ流れる。
4. **`svc #0x80` + `x16=0x25` (`kill`)** で `kill(getpid(), SIG***)` を叩き、self-SIGKILL/SIGABRT。
5. あるいは `__stack_chk_fail` @ `0x1007d442c` を発火させて `abort` 経路。
6. BRK 2 発 (`0x1007d98b0`, `0x1007d9a68`) も別の異常パス用 SIGILL trap。
`abort` / `exit` / `_exit` は import されているものの、initGenAudioH 本体からの BL は 0 (呼ばれない)。**すべての「殺し」は生 syscall or `__stack_chk_fail` or `brk` に集約されている**、これが Frida の `Interceptor.attach(abort, ...)` などが効かなかった理由。
**(訂正: 2026-07-03、詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §3 §7)**: 上記 4 の記述は誤り。3 箇所の `kill` は全て `sig=0` の良性 liveness probe であり self-kill ではない (§4-2 訂正脚注参照)。真の self-kill 経路 (もし存在するなら) はこの 3 箇所ではなく未確定のまま。`__stack_chk_fail` と `brk` 2 箇所は self-terminate 候補として引き続き未検証。
---
## 5. `_gen_audio_h` / `_gen_audio_h2` / `_set_platform_for_gen_audio` の Crew 内 VA
GameWidgetsExtension バイナリの既知 RVA から prologue 128 バイトを取り、Crew の全バイト列から exact match で探索した結果。
| シンボル | GameWidgetsExtension RVA | Crew VA | Crew RVA |
|---|---|---|---|
| `_gen_audio_h` | `0x4e9e7c` | **`0x10090f20c`** | `0x90f20c` |
| `_gen_audio_h2` | `0x3ceb40` | **`0x1007f425c`** | `0x7f425c` |
| `_set_platform_for_gen_audio` | `0x351d5c` | **`0x1007788ec`** | `0x7788ec` |
指紋長 64 バイト以上でユニークマッチ。
### 呼び出し関係 (Crew 側)
- `_gen_audio_h` は `bl` 経由で **1 箇所** から呼ばれる: `0x1007de96c` (親関数 `0x1007de890` .. `0x1007df124`)。この親関数は `+[VoIPClient genAudioH:i2:i3:]` (`0x10072b634`) から `bl @0x10072b85c` で呼ばれる。したがってフローは:
- `+[VoIPClient genAudioH:i2:i3:]` (`0x10072b634`) → wrapper (`0x1007de890`) → `_gen_audio_h` (`0x10090f20c`)。
- `_gen_audio_h2` は `bl` 直接は 0 回、`b` (uncoditional branch = tail call) が **1 箇所** `0x100819c34` から。この branch は `0x100819c34` を含む親 (`0x10090f20c` .. `0x100a208fc`) 内、つまり **`_gen_audio_h` 自体の末尾から `_gen_audio_h2` へジャンプ**する。つまり Crew では `gen_audio_h` と `gen_audio_h2` は tail-merged した 1 つの巨大関数として実装され、内部 dispatcher (hash_method によって v1 か v2 か振り分け) で切り替わる。
- `_set_platform_for_gen_audio` は `bl` 1 箇所: `0x1009593e8` (親関数 `0x10090f20c` .. `0x100a208fc`)。**`_gen_audio_h` の中で 1 回、platform をセットしている**。Nintendo 側では iOS/Android/ROOT/EMULATOR などの platform id を区別してホワイトボックスの分岐を変える設計。
### `+[VoIPClient genAudioH2:i2:i3:]` 側の経路
- IMP `0x100708a3c` の BL リストは import stub 中心。内部 BL は `0x1006d2834` (ORC-CoreVoIP 系ヘルパ, 未解析) と `0x1007650f8` (wrapper) の 2 個のみ。
- `0x1007650f8` は `0x10076fe7c` を 3 回呼び、`0x10076fe7c` は最後に `bl 0x1007d594c` (initGenAudioH 末尾のオブジェクト初期化ルーチン) を呼ぶ。genAudioH2 経路は共通の compound-init を lazy に叩いてから、`b 0x1007f425c` (=`_gen_audio_h2`) にテールジャンプする形。
---
## 6. まとめ
| 項目 | 結論 |
|---|---|
| `+[VoIPClient initGenAudioH]` IMP | `0x100a29528` (RVA `0xa29528`)。CFF 化された 0x7c バイトの薄いラッパー |
| 本体 | `0x100783fc8`〜`0x1007d594c` (0x51984 バイト、単一 basic block の OLLVM-CFF)。呼び出しは `0x100a2957c` から唯一 |
| ホワイトボックス表本体 | **`0x100f70000` .. `0x101192000` (約 2.13 MiB)** の `__DATA.__data` 内。Mach-O ローダにより自動で mmap される |
| ホワイトボックス表を `initGenAudioH` が読み込む rodata | **なし** (`__DATA_CONST` はほぼ触っていない)。関数の役割は「表のロード」ではなく「anti-debug チェック + ステート machine 初期化 + mutex/thread の生成」 |
| 素の AES SBox / MixColumns 定数 | バイナリ全体に露出せず (`63 7c 77 7b` パターン検索 hit 0)。全ラウンドが TFIT 化されている |
| anti-debug 手段 | 生 `sysctl(0xca)` × 10 (訂正: 2026-07-03、旧「12」は誤り。うち 4 箇所は実は `getpid(0x14)`。詳細 [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md)), 生 `kill(0x25)` × 3 (訂正: 2026-07-03、全て `sig=0` の liveness probe、self-kill ではない), dyld image walk, `dlsym(RTLD_NEXT, ...)`, `getenv(...)`, `opendir/readdir`, `access(0x21)` × 3, `symlink(0x39)` × 2, `unlink(0xa)` × 7, `__stack_chk_fail`, `brk` × 2 |
| Frida/Substrate 検出の crash 経路 | ~~libc `abort/exit` を使わず、`svc #0x80` 経由の生 `kill` に集約。したがって `Interceptor.replace(abort, ...)` などでは回避不能~~ **(訂正: 2026-07-03) 不正確。3 箇所の `kill` は全て `sig=0` の良性 liveness probe であり self-kill 経路ではないと判明** (詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §3)。真の crash 経路は未確定 |
| `_gen_audio_h` (Crew) | `0x10090f20c` (RVA `0x90f20c`) |
| `_gen_audio_h2` (Crew) | `0x1007f425c` (RVA `0x7f425c`) |
| `_set_platform_for_gen_audio` (Crew) | `0x1007788ec` (RVA `0x7788ec`) |
| `_gen_audio_h` と `_gen_audio_h2` の関係 (Crew) | `_gen_audio_h` の末尾 (`0x100819c34`) から `b _gen_audio_h2` へテールジャンプ。両者は tail-merged 実装 |
| コールチェーン (h1) | `+[VoIPClient genAudioH:i2:i3:]` `0x10072b634` → wrapper `0x1007de890` → `_gen_audio_h` `0x10090f20c` |
| コールチェーン (h2) | `+[VoIPClient genAudioH2:i2:i3:]` `0x100708a3c` → wrapper `0x1007650f8` → `0x10076fe7c` → `0x1007d594c` (compound-init) → tail の `b _gen_audio_h2` |
## 7. 未確定 (追加調査が必要)
**(訂正: 2026-07-03)** 以下 6 項目のうち 3 項目は後続調査で解決済みに移動した。詳細は各リンク先を参照。
### 解決済みに移った項目
- ~~`sysctl` MIB の具体値 (12 回それぞれで何を問い合わせているか)~~ → **確定**。`{CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1, pid}` (`kinfo_proc` の `P_TRACED` 検出パターン)。回数も 12 ではなく **10** に訂正。詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §2。
- ~~`kill(pid, sig)` の `sig` 値 (SIGKILL/SIGABRT/SIGILL の別)~~ → **確定**。3 箇所とも `sig=0`(良性の liveness probe、self-kill ではない)。詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §3。
- ~~`pthread_create` で起動される thread routine の VA (CFF 内で x2 が乗算/XOR で組み立てられており線形トレースでは復元不可)~~ → **確定**。`0x100a41050` (size `0x24c`)。実際には CFF は絡んでおらず単純な `adrp+add` で特定可能だった。`NSSearchPathForDirectoriesInDomains` + `CFArrayGetValueAtIndex` + `CFStringGetCString` を呼ぶループで、標準検索パスのファイル列挙(ジェイルブレイク痕跡探索候補)と判明。詳細は [`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md) §5。
### 依然未確定
- `getenv` / `dlopen` / `dlsym` に渡される文字列引数の実体 (難読化されており、runtime decode 器を先に特定してからでないと判読不能)。**依然未確定**: Unicorn Engine による動的エミュレーションでも、あるチェックループ内で 425,000,000 命令超を消費しても終了せず、これらの呼び出しに対応するコードパスへ到達できなかった。詳細は [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) §5。
- dyld image walk での比較先文字列 (`frida` / `substrate` / etc の平文値)。依然未確定 (呼び出し自体は動的に確認済みだが比較先文字列は未到達)。
- initGenAudioH のうち "実際にホワイトボックス state を書き換えている" 命令の同定 (state variable `0x101034204` は CFF ディスパッチ用の擬似変数であり、真のホワイトボックス初期化は既に mmap されたテーブルへの XOR マスク差し込みだけの可能性が高い。だとするとほぼ noop なのだが、それにしては function 本体が 335 KiB と巨大すぎ、CFF による膨張分を差し引いても実処理はまだ確定していない)。
→ **2026-07-03 CFF de-flatten で大幅進展**: [`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md) 参照。
694 件の CFF ディスパッチ site のうち **99.7% (678件) が state=0 によるフォールスルー相当の no-op (opaque predicate)** であり、
実行時の制御フローに影響を与える "functional edge" はわずか **15 個** (非ゼロ定数によるジャンプ 2 個 + 実行時値による動的分岐 13 個、うち `_dyld_image_count` の戻り値が直接分岐に使われる anti-inject 検知が 1 個確認済み) のみ。
また、ホワイトボックス領域への genuine な `str`(書き込み)は 0 件、読み出しのみ 498 件(68+ ページに分散)で、
マップ済みテーブルの書き換えは `initGenAudioH` 内には存在しないことも判明した。
### ホワイトボックス領域の内部レイアウト (部分解決)
- ホワイトボックス領域 (2.13 MiB) の内部レイアウト (T-box / round key / permutation-scaling 表の切り分け)。
→ **2026-07-03 追跡調査で大幅進展**: [`znca_ios_whitebox_layout.md`](./znca_ios_whitebox_layout.md) 参照。
ブロブの 82.6% (1.76 MiB) は "pure table" 領域(entropy 7.07-7.22 の狭帯域、4B ワードがほぼ全て distinct)、
残り 17.4% (380 KiB, 31 island) は chained-fixups で明らかになった **CFF ディスパッチ用ポインタ変数プール**
(本ドキュメント §3 で "未確認" としていた `0x10113a520` 等のホットターゲットの多くがこれに該当し、
`initGenAudioH` と `_gen_audio_h`/`_gen_audio_h2` が同一の state 変数プールを共有していることも判明)。
なお生の AES S-box(256B バイト置換、`0..255` の完全順列)はブロブ全域(固定境界・スライディング窓の両方)で
1 件もヒットせず、`_gen_audio_h` 側の逆アセンブルでもレジスタ添字 `LDRB`(古典的 S-box lookup 命令列)は 0 件と確認された
(較正用に実行した Netflix TFIT-AES11 の同テストでは 16/16 ヒットしており手法自体は妥当)。詳細は同ドキュメント §3, §5。
追加で `initGenAudioH` のマスクデコード実装を洗い出したい場合は、
- CFF を IDA/Ghidra + karonte や deflat スクリプトで de-flatten し、
- state variable `0x101034204` の書き込みを edge にした CFG 再構築
が最短ルートになる。
**(訂正: 2026-07-03)**: 上記の方針は実行済み。[`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md) が capstone ベースの自作パーサで de-flatten を行い、真の CFF state slot(693 個の private stack slot)と機能的に意味のある 15 エッジを特定した。ただし `getenv`/`dlopen`/`dlsym` の引数文字列や sysctl MIB の実 pid 値等、残された未確定項目については引き続き上記「依然未確定」節を参照。
+436
View File
@@ -0,0 +1,436 @@
# znca iOS 3.4.1 — `+[VoIPClient initGenAudioH]` フック時クラッシュの真の発生地点 再調査
対象: `/home/vscode/app/targets/znca_ios_3.4.1/Crew` (Mach-O arm64, `__TEXT` vmaddr = `0x100000000`)
## 0. 背景と訂正対象
`docs/spec/znca_ios_f_generation.md` §「anti-tweak / anti-debug 検出メカニズム」(2026-07-03 記載) は、Substrate で
`+[VoIPClient initGenAudioH]` をフックした際に発生した `EXC_BAD_ACCESS at 0x0` クラッシュについて、以下のように結論していた:
> コールスタック: `_logos_meta_method$..$VoIPClient$initGenAudioH` → `Crew@0x7D5B4` を **500 回以上再帰**して
> `objc_msgSend` でスタックオーバーフロー
> 判明: `initGenAudioH` は実行前に自己 IMP の integrity check を行い、swizzle 検出時に自分自身を無限再帰呼び出しでスタック破壊する
その後、`docs/spec/znca_ios_antidebug_strings.md` の追加調査で、旧 `znca_ios_init_gen_audio_h_static.md` が
self-kill 機構と推定していた 3 箇所の raw `kill()` 呼び出しが全て `sig=0` (生存確認のみ、自殺ではない) であることが
判明し、上記「500+ 再帰」説の実行メカニズムは未確定のまま残されていた。
本ドキュメントは、その未解決事項 — **`Crew@0x7D5B4` の正体と、真の "self-kill" 経路の有無** — を静的手法のみで
再調査した結果である。**結論を先に言うと、`0x7D5B4` は `initGenAudioH` と無関係な別関数の一命令の中間バイトであり、
「`initGenAudioH` が自己 IMP チェックで `objc_msgSend` 再帰する」という仮説はコード上不可能であることを、
バイナリ全域の網羅的スキャンにより反証した。** 詳細は §1〜§4 を参照。
補助スクリプトはすべて `targets/znca_ios_3.4.1/analysis/selfkill/` に配置。
---
## 1. `image_base + 0x7D5B4` (= VA `0x10007d5b4`) の正体
### 1-1. LC_FUNCTION_STARTS による関数境界の確定
`find_func.py` (`lief` の `function_starts` を `bisect` で検索) により、VA `0x10007d5b4` を含む関数の開始アドレスは
**`0x10007d5ac`** (16 バイト、次の関数開始 `0x10007d5bc`) と確定した。
これは `initGenAudioH` 本体 (`0x100783fc8`–`0x1007d594c`、`docs/spec/znca_ios_init_gen_audio_h_static.md` 確定値)
よりも **約 `0x706414` バイト (約 7.3 MiB) 手前** にあり、また `+[VoIPClient initGenAudioH]` ラッパー
(`0x100a29528`–`0x100a295a4`) からも約 `0x24C974` バイト (約 2.3 MiB) 離れている。**アドレス算術だけで、
`0x7D5B4` が本体にもラッパーにも属さないことが確定する。**
### 1-2. 実際の逆アセンブル (`disasm_target.py`)
```
--- dump @ 0x10007d5ac size=0x18 ---
0x10007d5ac: mov x1, x0
0x10007d5b0: adrp x0, #0x100c7a000
0x10007d5b4: add x0, x0, #0xc18 ; ← ここが "Crew@0x7D5B4"
0x10007d5b8: b #0x100c34fbc ; = _swift_getWitnessTable (stub、末尾参照確認済み)
0x10007d5bc: mov w0, #4
0x10007d5c0: ret
```
`0x7D5B4` は命令 `add x0, x0, #0xc18` の**命令中間バイト**であり、命令アドレスとしても不自然 (命令境界は
`0x10007d5b0`/`0x10007d5b4` のいずれかで、`0x10007d5b4` 自体は正しい命令境界だが、その命令は `objc_msgSend`
関連命令ではない)。この 16 バイト関数は「`x1=引数, x0=定数, b swift_getWitnessTable` という **Swift ランタイムの
witness table 取得を tail-call するだけのトランポリン**」であり、**`objc_msgSend` を一切呼ばない**。したがって
**「swizzle 検出時に `objc_msgSend` で自己再帰する」という仕組みをこの関数が実装することはアーキテクチャ上不可能**
(そもそも `objc_msgSend` へも `initGenAudioH` へも分岐しない)。
**結論: `Crew@0x7D5B4` は `initGenAudioH` の IMP-integrity-check とは無関係の、Swift ランタイム glue コードの
一部 (`swift_getWitnessTable` tail-call thunk) である。確定。**
---
## 2. `+[VoIPClient initGenAudioH]` の真の・唯一の呼び出し経路
### 2-1. 静的確認: 呼び出し元関数 `0x10007d5c4`
`0x7D5B4` のすぐ後ろ (`0x10007d5c4`) に、別の小関数が存在する。これが `initGenAudioH` を実際に呼ぶ
Swift `dispatch_once` ガード付き lazy-static 初期化 thunk である:
```
0x10007d5c4: stp x20, x19, [sp, #-0x20]!
0x10007d5c8: stp x29, x30, [sp, #0x10]
0x10007d5cc: add x29, sp, #0x10
0x10007d5d0: bl #0x10007d608
0x10007d5d4: adrp x1, #0x100f47000
0x10007d5d8: add x1, x1, #0x920
0x10007d5dc: bl #0x100c35004 ; = _swift_initStaticObject
0x10007d5e0: mov x19, x0
0x10007d5e4: adrp x8, #0x100f26000
0x10007d5e8: ldr x0, [x8, #0xe70] ; classref → VoIPClient class object (下記 2-2 参照)
0x10007d5ec: bl #0x100c34470 ; = _objc_opt_self
0x10007d5f0: bl #0x100c40240 ; = objc_msgSend$initGenAudioH ← ★ 実際の呼び出し ★
0x10007d5f4: adrp x8, #0x1013d5000 ; ← リターンアドレス (戻り先)
0x10007d5f8: str x19, [x8, #0x5d0]
0x10007d5fc: ldp x29, x30, [sp, #0x10]
0x10007d600: ldp x20, x19, [sp], #0x20
0x10007d604: ret
```
`+[VoIPClient initGenAudioH]` への実際の呼び出し命令は `0x10007d5f0` (`bl objc_msgSend$initGenAudioH`)、
**戻りアドレスは `0x10007d5f4`** である。
### 2-2. classref/selref のチェイン修正 (chained fixups) デコードによる裏付け
`decode_chained.py` で `DYLD_CHAINED_PTR_64` 形式 (`target = BASE + (raw & 0xFFFFFFFFF)`) を手動デコード:
| slot VA | raw | decode 先 VA | 内容 |
|---|---|---|---|
| `0x100f26e70` (classref, `0x10007d5e8` が参照) | `0x10000000f3e468` | `0x100f3e468` | **`VoIPClient` class object** (`znca_ios_init_gen_audio_h_static.md` 記載の VA と完全一致) |
| `0x100f1ec30` (selref、`objc_msgSend$initGenAudioH` stub 内部が参照) | `0x10000000d60d7a` | `0x100d60d7a` | 文字列 `b'initGenAudioH'` |
= `VoIPClient` クラスに対して `objc_opt_self` → `initGenAudioH` セレクタで `objc_msgSend` している、という
静的デコードが完全に裏付けられた。
### 2-3. `objc_msgSend$initGenAudioH` スタブ自体の確認
```
--- dump @ 0x100c40240 (objc_msgSend$initGenAudioH stub) ---
0x100c40240: adrp x1, #0x100f1e000
0x100c40244: ldr x1, [x1, #0xc30] ; x1 = selref → "initGenAudioH"
0x100c40248: adrp x16, #0x100e57000
0x100c4024c: ldr x16, [x16, #0xb0] ; x16 = &objc_msgSend (GOT)
0x100c40250: br x16 ; tail-branch to libobjc の本物の objc_msgSend
0x100c40254: brk #1 ; (次の per-selector stub の先頭パディング)
```
標準的な ObjC2 の per-selector `objc_msgSend$<sel>` スタブそのもので、`br x16` によりランタイムの
`objc_msgSend` へ tail-branch するだけ。**このスタブが再帰的に自分自身や `initGenAudioH` へ戻ることは
コード上あり得ない**(常に `libobjc.A.dylib` 側へ抜ける一方通行)。
### 2-4. バイナリ全域スキャン: この呼び出しは本当に「1 箇所だけ」か
`find_all_selrefs.py` で `__DATA_CONST`/`__DATA` 全域の chained-fixup スロットを走査し、
`"initGenAudioH"` 文字列 (`0x100d60d7a`) を指す selref を数え上げた:
```
total selref slots pointing to "initGenAudioH" string @ 0x100d60d7a: 1
0x100f1ec30 (seg __DATA)
```
さらに `find_bl_callers.py` (手動 A64 `bl` エンコード decode、`__TEXT` 全域 `0x100000000`–`0x100e54000` を
4byte 単位で総当り) で `objc_msgSend$initGenAudioH` スタブ (`0x100c40240`) への `bl` 呼び出し元を数え上げた:
```
target=0x100c40240: 1 callers
bl-site 0x10007d5f0
```
**確定: `+[VoIPClient initGenAudioH]` を呼び出す selref も `bl` サイトも、バイナリ全体でそれぞれ厳密に 1 箇所しか
存在しない。** すなわち `initGenAudioH` を objc_msgSend 経由で呼び出せるコードパスはこの 1 箇所のみであり、
**「500 回以上の再帰呼び出し」を実現するコードパスはバイナリ上に存在しない**(少なくとも直接 `objc_msgSend$<sel>`
スタブ経由の呼び出しでは物理的に不可能)。
### 2-5. 実測ログとの突合 (動的、確定)
このリポジトリに実在する Frida 実機ログ `targets/znca_ios_3.4.1/frida_znca_20260703_043544.log` に、
`+[VoIPClient initGenAudioH] ENTER` の実測バックトレースが記録されている:
```
backtrace:
0x1021d55f4 Crew!0x7d5f4 (0x10007d5f4)
0x1a2b90780 libdispatch.dylib!_dispatch_client_callout
0x1a2b60ddc libdispatch.dylib!_dispatch_once_callout
0x1021919a8 Crew!0x399a8 (0x1000399a8)
0x102191c00 Crew!-[Crew.AppDelegate application:didFinishLaunchingWithOptions:]
0x19e0a8644 UIKitCore!-[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:]
...
```
**PC が `Crew!0x7d5f4` — 上記 §2-1 で静的に導出した戻りアドレス `0x10007d5f4` と完全一致する。** また
`_dispatch_once_callout` 経由であることから、`initGenAudioH` は `AppDelegate.didFinishLaunching` 内の
Swift `dispatch_once`-guarded lazy static により**アプリ起動時に一度だけ**呼ばれることも実測で裏付けられた
(静的解析の「呼び出し元は 1 箇所のみ」という結論と整合)。このログ自体はクラッシュではなく正常な ENTER であるが、
「`Crew!0x7d5f4` が実在する正しいアドレスである」ことの動的な傍証として扱える。
---
## 3. `0x7D5B4` と `0x7D5F4` の食い違いについての所見
旧ドキュメントの `Crew@0x7D5B4` と、本調査で確定した実際の呼び出し元 `Crew@0x7D5F4` は、**下 2 桁が `B4` と
`F4` で異なるのみ**(16進数で `B` と `F` の一文字違い)であり、かつ両者は同一の極小関数クラスタ
(`0x10007d5ac`–`0x10007d608` の 92 バイトの範囲) に含まれる隣接命令である。
以下の理由から、**「`0x7D5B4` は `0x7D5F4` の転記ミス (`B`/`F` の視覚的取り違え)である」という説明が最も
節約的 (parsimonious) である**と判断した:
- 上記 §2-5 の実機ログには、Frida/r2 が自動生成した文字列として **`Crew!0x7d5f4`** がそのまま記録されている
(ランタイムベースからの引き算をこちらで再計算した値ではなく、ログ自体に既に「0x7d5f4」という文字列が
出力されている)。したがって「ランタイムベース差し引き計算の誤り」という可能性は排除できる。
- スタックオーバーフロー後のバックトレース破損 (SP がガードページを越えた後の frame-pointer unwind が
古い/繰り返しのスタック値を誤読する現象) という仮説も検討したが、`0x7D5B4` は `initGenAudioH` や
`objc_msgSend` 呼び出しと全く無関係な Swift witness-table thunk の命令中間アドレスであり、
**破損したバックトレースが偶然「本物っぽく見えるが無関係な」命令アドレスに着地する** というのは、
同じ 16 バイト関数の隣、かつ 1 文字違いのアドレスに着地する確率としては不自然に低い。転記ミス説の方が
単純に説明力が高い。
**注記 (正直な申告): これは状況証拠に基づく最有力仮説であり、100% の証明ではない。** 実際のクラッシュ発生時の
生の `.ips`/バックトレースファイルは本ワークスペースに存在せず、旧セッションがどのように `0x7D5B4` という値を
得たかの生ログも保存されていないため、「転記ミスであった」ことを直接証明することはできない。**この点は
「追跡不可」として明記する。**
---
## 4. 真の self-kill (非ゼロ signal) 経路の探索 — 結果: 発見できず (空振り)
### 4-1. `initGenAudioH` 本体内: 既知の 3 箇所の `kill` は `sig=0` (確定、既存ドキュメント通り)
`docs/spec/znca_ios_antidebug_strings.md` の結論を踏襲: `0x1007c9b14`/`0x1007cc560`/`0x1007cdd44` の 3 箇所は
いずれも `kill(pid, 0)` (liveness probe) であり、self-kill ではない。本セッションでは再検証していないが、
既存の静的+動的 (Unicorn エミュレーション) 両面の確認結果を訂正する新情報は得られなかった。
### 4-2. `brk` 命令のバイナリ全域スキャン (新規、本セッションで実施)
`scan_all_brk.py` で `__TEXT` セグメント全域 (`0x100000000`–`0x100e54000`) を 4byte 単位で総当りし、
BRK 命令のエンコード (`(word & 0xFFE0001F) == 0xD4200000`) に一致する箇所をすべて列挙した:
```
total BRK instructions found in __TEXT: 19601
brk #0x1 ×19599
brk #0xc00e ×1
brk #0x6380 ×1
INSIDE initGenAudioH body (0x100783fc8–0x1007d594c): 0 件
INSIDE wrapper (0x100a29528–0x100a295a4): 0 件
```
**`initGenAudioH` 本体・ラッパーいずれの範囲にも `brk` 命令は 1 件も存在しない。** `brk #0x1` が
19599 件という圧倒的多数を占めることから、これは Swift コンパイラが強制アンラップ失敗・配列範囲外アクセス・
整数オーバーフローなどの `precondition` 違反時に自動挿入する**標準的なランタイムトラップ**であり、
Swift で書かれたアプリ全体に遍在する。**アンチデバッグ/self-kill に特化した特殊な `brk` ではない**、
という所見も付記する (数の多さと分布の一様性から)。
### 4-3. 旧ドキュメントが「本体内 brk」としていた 2 箇所の訂正
`docs/spec/znca_ios_init_gen_audio_h_static.md` は `brk`×2 を `0x1007d98b0`/`0x1007d9a68`
(いずれも `brk #0x1`) として「`initGenAudioH` 本体内」に記載していた。`locate_brk_funcs.py` で
LC_FUNCTION_STARTS を用いてこれらのアドレスが実際にどの関数に属するかを確認したところ:
```
0x1007d98b0 -> containing function start=0x1007d94d0 end=0x1007d9bd0 size=0x700
0x1007d9a68 -> containing function start=0x1007d94d0 end=0x1007d9bd0 size=0x700
```
**この 2 箇所は `initGenAudioH` 本体 (`0x100783fc8`–`0x1007d594c`) の終端より `0x4108` バイト (約 16.5 KiB)
先にある、別の独立した関数 `0x1007d94d0`–`0x1007d9bd0` (サイズ `0x700`) に属する。** 旧ドキュメントの
「本体内」という記載は誤りであったことを本調査で確定した (訂正)。
### 4-4. この `brk` 保有関数は `initGenAudioH` と静的呼び出しグラフでつながっているか
`find_bl_callers.py`/`check_brk_caller_chain.py` で `bl` 呼び出しを手動デコードして呼び出し元を追跡した:
```
target=0x1007d94d0 (brk を含む関数): 1 callers
bl-site 0x1007f17fc (関数 0x1007f17ac–0x1007f18f4, initGenAudioH 本体域外)
target=0x1007f17ac (↑の呼び出し元関数): 2 callers
bl-site 0x100a2eed8 (関数 0x100a2ed34)
bl-site 0x100a2efdc (関数 0x100a2ed34)
```
`0x100a2ed34` は 3 引数 (`self`/`_cmd`/`arg` 相当の CFF 付き ObjC メソッドスタイル) を持つ別の小関数で、
`+[VoIPClient initGenAudioH]` のラッパー (`0x100a29528`、0 引数) とは**シグネチャも中身も異なる**別のメソッド
実装であり、これも `initGenAudioH` 本体域外に位置する。
**結論: 直接 `bl` による静的呼び出しグラフを最大 3 ホップ遡っても、この `brk` 保有関数
(`0x1007d94d0`) は `initGenAudioH` 本体にもラッパーにも到達しない。** ここまでの範囲では
**「本体外の `brk` で `initGenAudioH` と graph-connected な経路」は見つからなかった (空振り)**。
**留保 (追跡不可)**: 本調査の `bl` スキャンは直接分岐命令のみを対象としており、`initGenAudioH` 本体自体が
CFF (control-flow flattening) で `objc_msgSend`/`bl` を間接的に呼ぶケースや、ブロック (`imp_implementationWithBlock`
経由の invoke) やファンクションポインタテーブル経由の間接呼び出しは捕捉できていない。したがって
「`initGenAudioH` 本体からこの `brk` 関数への間接呼び出しが絶対に存在しない」とまでは証明できておらず、
**未確定・追跡不可**として明記する。
### 4-5. まとめ: self-kill 経路の探索結果
| 候補 | 結果 |
|---|---|
| `kill()` ×3 (`sig=0`) | liveness probe と確定。self-kill ではない (既存ドキュメント通り) |
| `brk`×2 (`0x1007d98b0`/`0x1007d9a68`) | **`initGenAudioH` 本体内ではないと訂正確定**。本体との静的 `bl` グラフ連結も見つからず (空振り、ただし間接呼び出しは追跡不可) |
| `brk` 全 19601 箇所 (バイナリ全域) | 本体・ラッパー内は 0 件。ほぼ全て `brk #0x1` = Swift 標準トラップで、アンチデバッグ専用のものとは考えにくい |
| `__stack_chk_fail` | 本セッションでは未着手 (**追跡不可**) |
| **「500+ 回の `objc_msgSend` 自己再帰」自体** | **§2-4 の全域スキャンにより、コード上不可能であることを確定** (selref/呼び出し site とも厳密に 1 箇所のみ) |
**正直な結論: 本セッションでは「真の self-kill (非ゼロ signal) 経路」を発見できなかった。** これは
「探索が不十分だった」というより、「そもそも `initGenAudioH` を objc_msgSend 経由で 2 回以上呼べるコードパスが
存在しない」という §2 の結果と合わせて考えると、**旧ドキュメントが観測したクラッシュの真因は
`initGenAudioH` 内部の自己再帰ロジックではなく、別のメカニズム (例: Substrate の hook 機構自体が `%orig` 経由で
何らかの不整合を起こした、あるいは `dispatch_once` フラグの二重初期化・フック注入によるスタック/レジスタ
破壊など、`initGenAudioH` の実装コードそのものではなく hook 機構との相互作用側の問題) である可能性が高い**、
という所見に留める。**この所見は状況証拠に基づく推定であり、確定事実ではない。**
---
## 5. ObjC hook 検出パターン / anti-tweak ライブラリ存在検出ロジックの探索
### 5-1. 候補 API の xref 総数 (r2 `axt`、既存調査の再確認)
| API | インポート先 VA | xref 数 | 呼び出し元 |
|---|---|---|---|
| `class_getMethodImplementation` | `0x100c33dec` | **0** | (呼ばれていない) |
| `class_replaceMethod` | `0x100c33df8` | **0** | (呼ばれていない) |
| `class_getInstanceMethod` | `0x100c33dd4` | 9 | `0x100559e04`, `0x100559ef8`, `0x10055bfc0`, `0x10055c0b4`, `0x100561ddc`, `0x100561fd8`, `0x100562170`, `0x1006947e8`, `0x10069493c` |
| `method_getImplementation` | `0x100c342cc` | 3 | `0x100559e04`, `0x10055bfc0`, `0x100561fd8` |
| `method_setImplementation` | `0x100c342e4` | 2 | `0x1006947e8`, `0x10069493c` |
| `imp_implementationWithBlock` | `0x100c3417c` | 1 | `0x100561ddc` |
**注目点**: `class_getMethodImplementation`/`class_replaceMethod` という「典型的な自己 IMP 検査 (現在の IMP を
取得して既知の値と比較する)」に使われがちな API は、**このバイナリでは一度も呼ばれていない**。これは
「`initGenAudioH` が `class_getMethodImplementation` で自己 IMP を検査し、想定外なら異常終了する」という
仮説に対する **直接的な反証** である。
### 5-2. `0x1006947e8` の同定: Firebase Cloud Messaging の swizzler と確定
`decode_swizzle_refs2.py` で `0x1006947e8` 内の直接文字列参照 (adrp+add、ldr を介さない直接ポインタ) を
デコードしたところ、以下の**完全な ObjC メソッドシグネチャ文字列**が埋め込まれていた:
```
0x100da9fa1 (直接参照, __TEXT): b'-[FIRMessagingRemoteNotificationsProxy swizzleSelector:inClass:withImplementation:inProtocol:]'
```
`FIRMessagingRemoteNotificationsProxy` は **Google の Firebase Cloud Messaging (FCM) SDK
(`FirebaseMessaging`/`GoogleUtilities`) 内の実装クラス名そのもの**であり、この文字列は Firebase SDK の
アサーション/デバッグメッセージとして Google 自身のソースに実在するものと一致する (プッシュ通知配送のための
`UIApplicationDelegate` メソッド swizzling を行うクラス)。`docs/spec/znca_ios_f_generation.md` の
「バイナリ構成」節で `FirebaseAnalytics.framework`/`GoogleAppMeasurement.framework` がアプリバンドルに
含まれていることも既に確認済みであり、Firebase 系ライブラリが静的にリンクされていること自体は既知の前提と
整合する。
**確定: `0x1006947e8` は Nintendo 独自のコードではなく、Firebase Cloud Messaging SDK 自身の
`GULAppDelegateSwizzler`/`FIRMessagingRemoteNotificationsProxy` 系メソッドスウィズリング処理
(プッシュ通知配送用の `AppDelegate` メソッド差し替え) が静的リンクによって Crew バイナリに埋め込まれたもの
である。** `class_getInstanceMethod` → `method_setImplementation`(or `class_addMethod`) →
`protocol_getMethodDescription` → `NSStringFromSelector`/`NSStringFromClass` というこの関数内の API 呼び出し
順序も、Google 製 swizzling ユーティリティの一般的な実装パターン(donor method 実装のコピー、既存実装との
衝突検知、デバッグログ)と整合する。
### 5-3. `0x100559e04` 等の残り 8 箇所も同一クラスタ由来と推定
`0x100559e04` は `class_getInstanceMethod`→`method_getImplementation`→`method_getTypeEncoding`→
`class_addMethod`→(条件分岐で早期 return) という、`0x1006947e8` とほぼ同型の骨格を持ち、`objc_retain`/
`objc_release`/`objc_retainAutoreleasedReturnValue` という同じ ARC ヘルパーの使い方、`NSStringFromSelector`
呼び出し、そしてエラーパスに Swift 由来と思われる「行番号定数 (`w8 = 0x3f1 = 1009`) + ファイル名文字列」の
組み合わせが見られる。これは Swift 側の `precondition`/`assert` ラッパーの典型パターンであり、
`0x1006947e8` (確定 Firebase) と**同一アドレス帯 (`0x1005xxxxx`) にあり、同型の API 骨格を共有する**ことから、
**同じ静的リンクされた Firebase/GoogleUtilities スウィズリングモジュールの一部である可能性が高い**、
という所見に留める (文字列の完全一致による確定はできていないため、確度は「中程度」)。
**いずれの swizzling 関連 9 サイトも `initGenAudioH` 本体 (`0x100783fc8`–`0x1007d594c`) やラッパー
(`0x100a29528` 付近) から地理的にも呼び出しグラフ的にも遠く離れた `0x1005xxxxx`–`0x1006xxxxx` 帯に集中して
おり、Nintendo 独自の anti-tweak ロジックであることを示す証拠はない。**
### 5-4. anti-tweak ライブラリ (ellekit/theos/frida-gum) の存在検出ロジック
Crew バイナリ全体に対して以下のキーワードで `strings -a` を実行したが、**一致なし (0 件)**:
```
substrate, ellekit, frida, cydia, cynject, MSHookFunction, libhooker,
xposed, TweakInject, frida-gum, gum-js, FridaGadget,
jailbreak, /Applications/Cydia, /bin/bash, /private/var/lib/apt,
MobileSubstrate, TweakLoader
```
**確定 (空振り): Crew バイナリ内に、既知の脱獄/フックフレームワークの名称を直接文字列比較する形の
検出ロジックは見つからなかった。** これは「そのような検出が存在しない」ことの証明ではなく、
「存在するとしても文字列としてハードコードされていない (`znca_ios_antidebug_strings.md` が指摘する
`symlink()` 引数の非 ASCII 24byte 列のような、実行時難読化/エンコードされた形である可能性が残る)」という
留保付きの null result である。**追跡不可**として明記する。
---
## 6. 最終まとめ
| 項目 | 結論 | 確度 |
|---|---|---|
| `Crew@0x7D5B4` の正体 | `initGenAudioH` と無関係な独立関数 (`0x10007d5ac`–`0x10007d5bc`, Swift `swift_getWitnessTable` tail-call thunk) の命令中間アドレス。本体からもラッパーからも約 2.3–7.3 MiB 離れている | **確定** |
| `initGenAudioH` の唯一の呼び出し元 | `0x10007d5c4`–`0x10007d604` (Swift `dispatch_once` lazy-static thunk)。実呼び出し命令 `0x10007d5f0`、戻り先 `0x10007d5f4` | **確定** (静的 chained-fixup デコード + 実機 Frida ログの PC 完全一致) |
| `objc_msgSend$initGenAudioH` スタブの呼び出し元数 (バイナリ全域) | **1 箇所のみ** (`0x10007d5f0`) | **確定** (`bl` エンコード総当りスキャン) |
| `"initGenAudioH"` selref 数 (バイナリ全域) | **1 箇所のみ** (`0x100f1ec30`) | **確定** (chained-fixup 総当りスキャン) |
| 「500+ 回の `objc_msgSend` 自己再帰」 | **コード上不可能と確定** (§2-4 の全域スキャンで反証) | **確定** |
| `0x7D5B4` と `0x7D5F4` の食い違いの原因 | 転記ミス (`B`/`F` 取り違え) が最有力。スタック破損によるバックトレース汚染説より説明力が高い | 推定 (状況証拠。**直接証明は追跡不可**) |
| `kill()`×3 (`sig=0`) | liveness probe。self-kill ではない | 確定 (既存ドキュメント通り、変更なし) |
| `brk`×2 (`0x1007d98b0`/`0x1007d9a68`) が本体内、という旧記載 | **誤り。訂正確定**。実際は本体末尾より 16.5 KiB 先の別関数 (`0x1007d94d0`–`0x1007d9bd0`) に属する | **確定** |
| その `brk` 保有関数と `initGenAudioH` の静的呼び出しグラフ連結 | 直接 `bl` を最大 3 ホップ遡っても未発見 (空振り) | 確定 (直接 `bl` の範囲内。間接呼び出しは**追跡不可**) |
| `brk` 命令の全域分布 (19601 件) | 本体・ラッパー内 0 件。大半 (19599件) は `brk #0x1` = Swift 標準トラップで遍在 | 確定 |
| 真の self-kill (非ゼロ signal) 経路 | **本セッションでも未発見 (空振り)**。`initGenAudioH` を 2 回以上呼べるコードパス自体が存在しないため、"検出時に自己を再帰的に呼んで自殺する" という機構自体が成立し得ないという新たな確定的傍証を得た | 確定 (機構の不成立) / 経路自体は未確定 |
| `class_getMethodImplementation`/`class_replaceMethod` | 0 xref (未使用) | **確定** |
| swizzling 関連 9 サイトの帰属 | `0x1006947e8` は **Firebase Cloud Messaging (`FIRMessagingRemoteNotificationsProxy`) の swizzler と文字列で確定**。残り 8 サイトも同型パターンから同モジュール由来と推定 (中確度)。Nintendo 独自の anti-tweak ロジックである証拠はなし | 確定 (1件) / 推定 (8件) |
| ellekit/theos/frida-gum の文字列ベース存在検出 | 全域 `strings` 検索で 0 件 (空振り)。存在しないと断定はできない (難読化の可能性は残る) | **追跡不可** (null result) |
### 6-1. 旧ドキュメントへの訂正提案
`docs/spec/znca_ios_f_generation.md` §「anti-tweak / anti-debug 検出メカニズム」の以下の記述は、
本調査により**反証されたものとして訂正が必要**:
- ~~「`initGenAudioH` は実行前に自己 IMP の integrity check を行い、swizzle 検出時に自分自身を無限再帰呼び出しでスタック破壊する」~~
→ **§2-4 の全域スキャンにより、`initGenAudioH` を 2 回以上呼び出すコードパス自体が存在しないため、この
メカニズムはコード上不可能であることが確定した。**
- ~~`Crew@0x7D5B4` を 500 回以上再帰~~
→ **`0x7D5B4` は無関係な独立関数の命令アドレスであり、`initGenAudioH` とは何の呼び出し関係もない。**
一方で、実際に発生した `EXC_BAD_ACCESS at 0x0` クラッシュ自体の実在性は否定しない (旧セッションの実機観測を
疑う根拠はない)。**真因は `initGenAudioH` 内部の自己再帰ロジックではなく、Substrate hook 機構と
`dispatch_once`/ObjC ランタイムとの相互作用側にある可能性が高い**、という所見のみ残し、確定的な代替説明は
本セッションでは得られなかった (**追跡不可**)。
---
## 7. 未確定・追跡不可のまま残る項目 (正直な申告)
- `0x7D5B4`→`0x7D5F4` の食い違いが本当に転記ミスなのか、旧セッションが依拠した生のクラッシュログ/バックトレースが
別の要因で異なる値を報告したのか — **生の `.ips`/ログが本ワークスペースに残っていないため証明不可能。追跡不可。**
- `initGenAudioH` 本体から `brk` 保有関数 (`0x1007d94d0`) への**間接呼び出し** (CFF 経由の `br`、ブロック
invoke 等) の有無 — 直接 `bl` スキャンでは連結なしと確認したが、間接呼び出しは本調査手法では捕捉できない。
**追跡不可。**
- `__stack_chk_fail` が self-kill 経路として使われているかどうか — 本セッションでは着手していない。
**追跡不可 (次セッションの候補)。**
- ellekit/theos/frida-gum の文字列を使わない、難読化された形での hook framework 検出ロジックの有無 —
`znca_ios_antidebug_strings.md` が指摘した「到達できなかった長大なループ」「`getenv`/`dlopen`/`dlsym` の
実引数」がこれに該当する可能性はあるが、本セッションでも到達できていない。**追跡不可。**
- `0x100559ef8`/`0x10055bfc0`/`0x10055c0b4`/`0x100561ddc`/`0x100561fd8`/`0x100562170` (Firebase 由来と
推定した残り 8 サイトのうち未逆アセンブルの分) の完全な逆アセンブルによる文字列同定 — 時間の都合上
`0x100559e04` のみ完全に確認し、他は API 骨格の類似性からの推定に留めた。**中確度の推定のまま。**
---
## 付録: 成果物一覧
- `targets/znca_ios_3.4.1/analysis/selfkill/find_func.py` — LC_FUNCTION_STARTS から `0x7D5B4` 含有関数を特定
- `targets/znca_ios_3.4.1/analysis/selfkill/disasm_target.py` — `0x10007d5ac`–`0x10007d608` 領域 + `objc_msgSend$initGenAudioH` スタブの逆アセンブル
- `targets/znca_ios_3.4.1/analysis/selfkill/resolve_stub.py` / `resolve_stub2.py` — GOT スロット → bind シンボル名解決 (`_swift_getWitnessTable`/`_swift_initStaticObject`/`_objc_opt_self`)
- `targets/znca_ios_3.4.1/analysis/selfkill/decode_chained.py` — classref/selref の chained-fixup 手動デコード (VoIPClient classref, "initGenAudioH" selref)
- `targets/znca_ios_3.4.1/analysis/selfkill/read_refs.py` — 上記デコードの補助読み出しヘルパ
- `targets/znca_ios_3.4.1/analysis/selfkill/find_all_selrefs.py` — `"initGenAudioH"` selref のバイナリ全域総当り検索 (結果: 1件のみ)
- `targets/znca_ios_3.4.1/analysis/selfkill/find_bl_callers.py` — 任意ターゲットへの `bl` 呼び出し元のバイナリ全域総当り検索 (手動 A64 命令デコード)
- `targets/znca_ios_3.4.1/analysis/selfkill/scan_all_brk.py` — `__TEXT` 全域の `brk` 命令総当りスキャン (19601件検出)
- `targets/znca_ios_3.4.1/analysis/selfkill/locate_brk_funcs.py` — 既知 `brk` サイトの所属関数を LC_FUNCTION_STARTS で確定 (本体外と訂正)
- `targets/znca_ios_3.4.1/analysis/selfkill/check_brk_caller_chain.py` — `brk` 保有関数から `initGenAudioH` への静的呼び出しグラフ連結有無の確認
- `targets/znca_ios_3.4.1/analysis/selfkill/decode_swizzle_refs.py` / `decode_swizzle_refs2.py` — swizzling 関連関数内の文字列/参照デコード (`FIRMessagingRemoteNotificationsProxy` 文字列の発見)
- 本ドキュメント: `docs/spec/znca_ios_true_selfkill.md`
## 関連ドキュメント
- [`znca_ios_f_generation.md`](./znca_ios_f_generation.md) — 訂正対象の旧記述を含む、f-token 生成の全体像
- [`znca_ios_init_gen_audio_h_static.md`](./znca_ios_init_gen_audio_h_static.md) — `initGenAudioH` 静的解析 (本体境界確定、旧 `brk` 帰属の訂正元)
- [`znca_ios_init_gen_audio_h_cff.md`](./znca_ios_init_gen_audio_h_cff.md) — CFF de-flatten、`kill` の zone 帰属不能の確認
- [`znca_ios_antidebug_strings.md`](./znca_ios_antidebug_strings.md) — `kill` の `sig=0` 確定、`sysctl` MIB 確定、self-kill 経路未確定の申告元
+332
View File
@@ -0,0 +1,332 @@
# znca (Nintendo Switch Online) iOS 3.4.1 — whitebox table 内部レイアウト解析
対象: `/home/vscode/app/targets/znca_ios_3.4.1/Crew` (arm64 Mach-O, `__TEXT` vmaddr=`0x100000000`)
抽出済みブロブ: `targets/znca_ios_3.4.1/whitebox/whitebox_data_0x100f70000_0x101192000.bin`
VA 範囲: `0x100f70000` .. `0x101192000`(2,236,416 バイト、2.133 MiB)、`__DATA.__data` 内。
前提となる静的解析: [`znca_ios_init_gen_audio_h_static.md`](./znca_ios_init_gen_audio_h_static.md)(`+[VoIPClient initGenAudioH]` の CFF 構造・64KiB 窓エントロピー走査で本ブロブ範囲を最初に特定)。
f-token 生成の全体像: [`znca_ios_f_generation.md`](./znca_ios_f_generation.md)。
このドキュメントは前セッションで生成されたが計算コードを失った 5 種の `.npy`
(`entropy_256`, `perm_cols_1024`, `distinct_rows_1024`, `rebase_per_4k`, `chain_per_4k`/`chain_mask`)
を shape・値域から逆算して `whitebox/tools/*.py` として書き起こし、その上で
**新規のポインタ fixup 解析** を追加してブロブの内部構造を大きく前進させた記録。
---
## 1. 復元した生成コード
全スクリプトは `targets/znca_ios_3.4.1/whitebox/tools/` に配置。`common.py` が共通定数
(`VA_LO=0x100f70000`, `VA_HI=0x101192000`, ブロブ/バイナリパス) を提供する。
| スクリプト | 再現対象 npy | 検証結果 |
|---|---|---|
| `gen_entropy_256.py` | `entropy_256.npy` (8736,) float64 | **`--verify` で完全一致** (256B 非重複窓の Shannon entropy) |
| `gen_distinct_rows_1024.py` | `distinct_rows_1024.npy` (2184,) int32 | **`--verify` で完全一致** (1024B ブロックを 256 行 x 4 バイトに reshape → distinct row 数) |
| `gen_perm_cols_1024.py` | `perm_cols_1024.npy` (2184,) int32 | **`--verify` で完全一致**(32x32 バイト行列の各列が `0..31` の順列かを判定。全 0 は妥当 — 後述) |
| `gen_pointer_fixups.py` | ~~`rebase_per_4k.npy` / `chain_per_4k.npy` / `chain_mask.npy`~~ → 新規 `pointer_fixup_*.npy` | **旧 npy とは一致しない(後述)。LIEF による正しい chained-fixups 解析で置き換え** |
| `gen_layout_segments.py` | (新規) | ブロブを "pure table" / "CFF plumbing" に分割する要約ツール |
| `scan_gen_audio_h_refs.py` | (新規) | `_gen_audio_h`/`_gen_audio_h2` 本体の ADRP+ADD / LDRB / 添字 LDR を capstone で走査 |
再現・検証コマンド:
```bash
cd targets/znca_ios_3.4.1/whitebox/tools
python3 gen_entropy_256.py --verify
python3 gen_distinct_rows_1024.py --verify
python3 gen_perm_cols_1024.py --verify
python3 gen_pointer_fixups.py --save
python3 gen_layout_segments.py
python3 scan_gen_audio_h_refs.py
```
### 1.1 `entropy_256` / `distinct_rows_1024` — パラメータ確定の経緯
- `entropy_256`: ブロブサイズ `2236416 / 256 = 8736` と shape が完全一致するため、
256B 非重複窓と確定。Shannon entropy (bits/byte) をそのまま計算し、`np.allclose` で完全一致。
- `distinct_rows_1024`: shape `2184 = 2236416/1024`。row 幅を `{4,8,16,32,64}` バイトで総当たりし、
**row 幅 4 バイト (= 256 行/ブロック) のときのみ** saved npy の `max=256` と一致
(row 幅 N バイトなら行数は `1024/N` なので、`max` が厳密に 256 になるのは行数がちょうど 256 のときだけ)。
4 バイト粒度は AES ラウンド鍵ワードや T-box 列エントリの典型サイズと一致し、
「1KB ブロック内で 4B ワードがどれだけ重複しているか」を見るテストと解釈できる。
- `perm_cols_1024`: 1024B を 32x32 行列とみなし、各列 (32 バイト) をソートして `[0..31]` と
一致するかを数える定義で **saved npy(全ブロックで 0)と完全一致**。ただしこの基準は
バイト値域が `0..255` である実データに対しては事実上成立しえない(32 バイトが `0..31` の
狭い範囲に収まり、かつ完全一致する確率は `32!/32^32 ~ 1e-14`)。**全 0 は「順列構造が
存在しない」ことの証明ではなく、「row-major 32x32・mod-32 という枠組みでは見つからなかった」
ことの証明**に過ぎない。今回あらためて **256B 窓でのフルバイト順列(`0..255`)を
固定境界・スライディング窓の両方で全域探索**したが、こちらもヒット 0 件だった(§3 参照)。
---
## 2. ポインタ fixup 解析(新規・legacy npy を置き換え)
### 2.1 legacy npy の不整合
前セッションの `rebase_per_4k.npy`(sum=69, max=6)と `chain_per_4k.npy` / `chain_mask.npy`
(sum=2149, max=261, True count=2149)は、生成コード紛失につき厳密な再現ができなかった。
LIEF (`lief.MachO.Binary.relocations`) で `LC_DYLD_CHAINED_FIXUPS` を正規にたどった
グラウンドトゥルースと比較すると、**どちらの npy とも一致しない**:
| ソース | ブロブ範囲内の rebase 数 |
|---|---|
| legacy `rebase_per_4k` (sum) | 69 |
| legacy `chain_per_4k` / `chain_mask` (sum / True count) | 2149 |
| legacy 合計 | 2218 |
| **LIEF 正規 chained-fixups 解析(本セッションで新規取得)** | **2775** |
legacy の値は恐らく手書きの chain-walk(page_size を 4KiB と誤認、あるいは chain の
一部だけを辿る等のバグ)による部分的な結果と推定される。本セッションでは
**LIEF の `dyld_chained_fixups` パーサ(`page_start` + `next` delta の正規リンクリスト走査)**
を直接使い、`gen_pointer_fixups.py` で全 2775 件を確定させた。念のため
`DYLD_CHAINED_PTR_64_OFFSET` のビットレイアウトを手動でデコードして 1 件をクロス検証済み
(`target:36 | high8:8 | reserved:7 | next:12 | bind:1`、`next` は 4 バイト単位ストライド)。
```
raw = 0x10000000f6ba3c @ VA 0x100f77ec0
target(offset) = 0xf6ba3c -> VA 0x100f6ba3c # LIEF の r.target と完全一致
high8=0 reserved=0 next=2(=8B先) bind=0 # 純粋 rebase (bind無し)
```
### 2.2 新しい成果物
`gen_pointer_fixups.py --save` が生成:
| ファイル | shape | 内容 |
|---|---|---|
| `pointer_fixup_per_4k.npy` | (546,) int64 | 4KiB bin ごとの fixup 数(グラウンドトゥルース) |
| `pointer_fixup_mask.npy` | (279552,) bool | 8B スロット単位で fixup 位置かどうか(True count = 2775) |
| `pointer_fixup_targets.npy` | (2775, 2) int64 | 各 fixup の `(address, target)` VA ペア |
**全 2775 件は `has_symbol=False`(純粋 rebase、import bind なし)**。
うち 2734 件はブロブ内部を指し(自己参照)、41 件のみブロブ外
(`0x100eb3cb8` 付近 / `0x1013cfd04` 付近 = 静的解析ドキュメントで既知の `__bss` フラグ領域・
`__common` C++ オブジェクトスロット領域)を指す。
### 2.3 決定的な発見: fixup 位置は "CFF ディスパッチ用ポインタ変数" と一致する
`znca_ios_init_gen_audio_h_static.md` §3 が列挙した「ホットな `__DATA.__data` 参照先
(疑いなく CFF ディスパッチ state 変数)」12 個のうち **11 個が、まさに chained-fixup の
ポインタスロットそのもの** だった:
| アドレス | initGenAudioH からの参照回数 | fixup 位置か |
|---|---|---|
| `0x101034204` | 628 | **いいえ**(真の int32 state 変数) |
| `0x10113a520` | 108 | **はい** |
| `0x10103d998` | 24 | **はい** |
| `0x10103f9e0` | 20 | **はい** |
| `0x101084300` | 16 | **はい** |
| `0x10110e020` | 16 | **はい** |
| `0x10113a408` | 12 | **はい** |
| `0x10110f1f0` | 12 | **はい** |
| `0x1010426e0` | 12 | **はい** |
| `0x10111c630` | 12 | **はい** |
| `0x101041cd0` | 12 | **はい** |
| `0x10103faa8` | 12 | **はい** |
**解釈**: OLLVM 系 control-flow flattening の一部の実装では、次に飛ぶ基本ブロックの
アドレスを「整数ステートキー」ではなく「rebase 済みポインタ変数」として保持し、
`br xN`(間接分岐)で直接ジャンプする。PIE バイナリではこの手のポインタは
ロード時に ASLR スライドを反映して書き換える必要があるため、**Mach-O ローダの
chained-fixups の対象になる** — これが `__DATA.__data` の同じ VA レンジ内に
「本物の暗号定数テーブル」と「CFF 用の間接分岐ポインタ」が混在している理由。
`0x101034204` だけ fixup でないのは、これが唯一の**真の整数ステートキー**
(`initGenAudioH` 冒頭で観測された `br x8` ディスパッチの `state` そのもの)だからと考えられる。
### 2.4 `_gen_audio_h` 側でも同一クラスタを再利用
`scan_gen_audio_h_refs.py` で `_gen_audio_h`/`_gen_audio_h2` tail-merged 本体
(`0x10090f20c`..`0x100a208fc`, 約 1.07 MiB, capstone で 279,996 命令) の
ADRP+ADD ターゲットを全走査した結果:
- ブロブ内ターゲット: distinct **9323** 種、参照 **14227** 回
- そのうち **fixup スロットに一致するもの**: distinct 306 種、参照 885 回
- **`0x101034204`(initGenAudioH と同一アドレス)が単独で 1385 回参照** — つまり
`initGenAudioH` と `_gen_audio_h`/`_gen_audio_h2` は **同一の CFF state 変数プールを共有**している。
---
## 3. S-box / permutation テーブル探索(陰性結果)
比較対象として Netflix iOS 15.48.1 の `NFWebCrypto.framework`(同じ Irdeto TFIT 系ホワイトボックス、
`tools/re/analyze_tfit_nfwc.py` で解析済み)を実際に抽出・実行して較正した:
```
TFIT_out_iAES11_0..15 (Netflix, 256B 各, offset 0x1DDBA8 から 0x100 刻みでアライン)
→ is_permutation=True, entropy=8.00 を全 16 テーブルで確認
```
同じ「256B 窓が `0..255` の完全順列かどうか」判定を znca のブロブ全域に適用:
| 探索方法 | ヒット数 |
|---|---|
| 256B 固定境界(8736 窓、非重複) | **0** |
| 256B スライディング窓(全 2,236,161 オフセット、1 バイト刻み) | **0** |
**結論**: znca のホワイトボックス実装には、Netflix TFIT-AES11 の `TFIT_out_*` に相当する
「生の 256 エントリ・バイト置換 S-box」がバイト境界のどこにも存在しない。
これは以下のいずれかを示唆する:
1. スキームが AES Chow-WBC 系ではない、または S-box を別の粒度(nibble 単位、
ワード単位の合成 T-box のみ)で実装している。
2. `f_generation.md` に記載の通り libvoip は CryptoPP の **SHA-256** 実装を内部に持つ
(HMAC は使われていない)ため、AES ではなく SHA-256 ベースの独自ホワイトボックス構成である
可能性がある。
3. テーブルは静的には難読化(XOR マスク等)されており、`initGenAudioH` の
ランタイム "unlock" パス(§4 参照)を経るまでは真の S-box 値が現れない。
`znca_ios_init_gen_audio_h_static.md` が既に確認済みの「生 AES SBox 定数
(`63 7c 77 7b`) もバイナリ全体で 0 ヒット」と合わせ、**素の AES 定数は静的解析では
一切観測できない**ことが再確認された。
---
## 4. ブロブのセグメンテーション: "pure table" 領域 vs "CFF plumbing" 領域
`gen_layout_segments.py` は fixup 密度が非ゼロの 4KiB bin を(最大 2 bin の隙間を許容して)
連結し、31 個の "CFF-plumbing island" を検出した:
```
CFF-plumbing 合計: 95/546 bins, 380.0 KiB (17.4%)
pure-table 合計: 451/546 bins, 1.76 MiB (82.6%)
pure-table entropy: min=7.075 max=7.217 mean=7.173 (非常に狭い帯域)
pure-table distinct 4B rows: min=252.2 max=256.0 mean=256.0 (ほぼ完全にユニーク)
```
対照的に CFF-plumbing island 側は entropy 6.15〜7.13(明確に低い)、distinct-row
217〜254(明確に低い)と、統計的に別集団であることがはっきり分離できる
(table 4-2 参照 — pure 側は分散がほぼゼロで、まさに「等方性の高い擬似乱数ルックアップテーブル」
の指紋。island 側はワードの反復があり構造化データの指紋)。
代表的な island(VA, サイズ, fixup 数):
| VA 範囲 | サイズ | fixups | 備考 |
|---|---|---|---|
| `0x100f77000`-`0x100f7f000` | 32 KiB | 345 | ブロブ先頭付近、最大密度 bin (`0x100f7b000`, 275/512 slot) を含む |
| `0x10103d000`-`0x101043000` | 24 KiB | 278 | `0x10103d998`/`0x10103f9e0`/`0x101041cd0`/`0x10103faa8` 系の CFF state を含む |
| `0x10110a000`-`0x101111000` | 28 KiB | 250 | `0x10110e020`/`0x10110f1f0` 系 |
| `0x101033000`-`0x101038000` | 20 KiB | 139 | `initGenAudioH` の主要 dispatch (`0x101034204`) を含む窓 |
| `0x1010b5000`-`0x1010bc000` | 28 KiB | 220 | 未対応の追加クラスタ(別の OLLVM 対象関数由来と推定) |
**解釈**: `__DATA.__data` の当該 VA レンジは、Crew バイナリ内の**多数の異なる
OLLVM-CFF 化関数**(`initGenAudioH`, `_gen_audio_h`, `_gen_audio_h2` に限らない)
が共有する「ディスパッチ用ポインタ変数プール」と、**本物のホワイトボックス定数テーブル**
が同居している。真のホワイトボックステーブル候補は **82.6% ≒ 1.76 MiB** に絞り込める。
---
## 5. `_gen_audio_h` の実データアクセスパターン
### 5.1 命令クラス別集計 (capstone, 279,996 命令走査)
| クラス | 件数 |
|---|---|
| ADRP+ADD ペアで解決したデータ参照(distinct target) | 11,944 |
| 同・総参照回数 | 17,474 |
| レジスタ添字 `LDRB` (`[Xn, Wm]` 形式の真の table-lookup) | **0** |
| レジスタ添字 `LDR Qn, [Xn, Xm]`(128bit SIMD、レジスタ+レジスタ addressing) | **8** |
**真のスカラー添字バイトロード(古典的 S-box lookup 命令列)は 1 件も検出されなかった。**
唯一の register-indexed アクセスは 8 箇所の `ldr q0, [x9, x8]` 系 128bit ロードで、
これは「16 バイト単位の行を register offset で読む」パターン — T-box の 1 行
(16B/32B ワードなど)をベクトルレジスタへ一括ロードする実装が候補になる
(8 箇所全ての周辺コンテキストと `x9`/`x10` の base register の由来は CFF に埋もれており未解決。
次の調査ステップ候補として明記)。
### 5.2 immediate-offset LDRB の大量出現(7271 件)はテーブルではなくスタック/CFF フィールド
`ldrb w8, [x28, #0x19e]` のような **即値オフセット**の LDRB が 7271 件観測されたが、
これらは全て小さいオフセット(0x19e など、〜0x400 以下)で `x28` を base にしており、
`initGenAudioH` の巨大スタックフレーム(約 90 KiB、`znca_ios_init_gen_audio_h_static.md` 参照)と
同様の「フレーム内ワーキングバッファへのフィールドアクセス」と解釈するのが妥当。
**ブロブ内アドレスへの参照ではない**(ADRP+ADD 経由の ADRP ベースアドレスとは別系統)。
### 5.3 ADRP+ADD ターゲットの大半は CFF クラスタに集中、残りは疎な "1 参照" アクセス
ブロブ内ターゲット 9323 種のうち、fixup スロットと一致しないもの (9017 種, 13342 参照)
から `0x101034204` を含む主要クラスタ (VA `0x101030000`-`0x101038000`) を除外すると:
```
残り distinct targets: 7301, 総参照: 8507
ref count == 1 のもの: 6733 (92.2%)
分布先の 4KiB bin: 420/546 (77%) に及ぶ、bin あたり平均 17.4 箇所
```
**解釈**: 除外後の大多数(92%)は 1 回しか参照されないユニークなアドレスで、
546 bin 中 420 bin(ほぼ全域)に広く分散している。これは「動的な添字計算による
1 つのテーブルへの繰り返しアクセス」ではなく、**CFF によって展開された数千個の
擬似ベーシックブロックそれぞれが、静的に確定した固有オフセットで 1 回だけ
テーブルセルを読む」という、Chow/TFIT ホワイトボックスの典型的な OLLVM 難読化後の姿**と整合する。
つまり「動的インデックス付き lookup」ではなく「コンパイル時に確定した数千通りの
分岐先ごとに 1 セルだけ触る」形へ CFF が変換した結果、動的な `LDRB (register)` が
消失し、代わりに大量の一意な即値オフセット ADRP+ADD/LDR が残っている、という仮説。
---
## 6. サブテーブルサイズの仮説(弱い証拠・要検証)
pure-table 領域サイズ 1.76 MiB (1,847,296 B) を Netflix TFIT-AES11 の
1 セット分の定数サイズ(`tools/re/analyze_tfit_nfwc.py` 実測 = 203,904 B, 199.1 KB)で割ると:
```
1,847,296 / 203,904 ≈ 9.06
```
ほぼ整数の 9 に近い。`_set_platform_for_gen_audio` が Nintendo 側で iOS/Android/ROOT/EMULATOR
等の platform id を区別する(`znca_ios_init_gen_audio_h_static.md` §5)ことを踏まえると、
「プラットフォームや鍵バリアント違いの完全なホワイトボックスセットが複数(候補: 9 セット)
連続配置されている」という仮説が立つ。ただし:
- FFT ベースの自己相関解析では 91 KiB / 183 KiB 付近に弱いピーク(相関係数 0.05〜0.07、
ノイズに近い水準)が見えるのみで、**205 KiB 周期を裏付ける強い証拠にはなっていない**。
- CFF-plumbing island の除去粒度(4KiB bin, gap tolerance=2)による pure-table サイズの
見積り誤差が数 % あり、"9.06" という数字自体に丸め誤差が乗っている可能性が高い。
**この仮説は未確定として次セッションへ引き継ぐ**。検証手段の候補:
- pure-table 領域を 205 KiB 刻みで分割し、各セグメントの entropy/distinct-row 分布を
比較(本当に 9 セットあるなら統計的に酷似するはず)。
- `_set_platform_for_gen_audio` (`0x1007788ec`) の実装を deflatten して platform id →
テーブルベースアドレスのオフセット計算式を特定する。
---
## 7. まとめ表
| 項目 | 結論 |
|---|---|
| ブロブ全体 | `0x100f70000`-`0x101192000` (2.133 MiB), `__DATA.__data` |
| entropy_256 / distinct_rows_1024 / perm_cols_1024 | 生成コード復元済み・完全一致で再現確認 (`gen_*.py --verify`) |
| legacy rebase_per_4k / chain_per_4k / chain_mask | **再現不能(恐らくバグ入りの手動 chain walk)。LIEF 正規解析で置換** (`gen_pointer_fixups.py`) |
| ブロブ内の chained-fixup ポインタ | **2775 件**(純粋 rebase、bind なし)。79/546 (4KiB bin) に偏在 |
| ポインタ位置の正体 | `initGenAudioH` 静的解析で見つかった「CFF ディスパッチ state 変数」12 個中 11 個と一致 → **間接分岐用のポインタ変数**であり暗号定数ではない |
| ブロブのセグメンテーション | CFF-plumbing 17.4% (380 KiB, 31 island) / pure-table 82.6% (1.76 MiB) — entropy・distinct-row の両方で統計的に明確に分離可能 |
| 素の AES S-box (256B 順列) | 固定境界・スライディング境界どちらの探索でも **0 件**(Netflix TFIT-AES11 の同テストでは 16/16 ヒットしており較正は妥当) |
| `_gen_audio_h` のレジスタ添字 LDRB | **0 件**(古典的動的インデックス lookup 命令列が存在しない) |
| `_gen_audio_h` のレジスタ+レジスタ `LDR Qn` | 8 件(128bit 行ロード候補、base register 由来は未解決) |
| CFF クラスタ除外後の ADRP+ADD ターゲット | 92% が参照 1 回、546 bin 中 420 bin(77%)に分散 — CFF 展開後のテーブルセル単発アクセスと整合 |
| サブテーブル数仮説 | pure-table サイズ ÷ Netflix TFIT-AES11 定数サイズ ≈ 9.06(プラットフォーム変種 9 セット?) — **弱い証拠、未確定** |
## 8. 未解決事項 (次セッションへ)
1. `ldr q0, [x9, x8]` 8 箇所の base register (`x9`/`x10`) が指すブロブ内アドレスの特定
(CFF に埋もれており線形トレース不可、deflatten が必要)。
2. §6 の「9 サブテーブル」仮説の統計的検証(205 KiB 刻みでの分布比較)。
3. `_set_platform_for_gen_audio` (`0x1007788ec`) の deflatten によるプラットフォーム分岐先アドレス式の特定。
4. ブロブが静的には難読化されている可能性(§3 の SBox 陰性結果を踏まえ、`initGenAudioH` の
スレッド起動ルーチン内でのマスク解除処理を追う)。`pthread_create` の thread routine VA は
`znca_ios_init_gen_audio_h_static.md` 未解決事項としても未特定のまま。
5. pure-table 領域の最小構成単位(4B ワード? 16B 行? 4096 エントリ x 4B の Chow T-box?)を
確定させるための、より高次の統計テスト(列/行方向の線形性検定、GF(2^8) 上の構造検定など)。
## 9. 生成物一覧
- `targets/znca_ios_3.4.1/whitebox/tools/common.py`
- `targets/znca_ios_3.4.1/whitebox/tools/gen_entropy_256.py`
- `targets/znca_ios_3.4.1/whitebox/tools/gen_distinct_rows_1024.py`
- `targets/znca_ios_3.4.1/whitebox/tools/gen_perm_cols_1024.py`
- `targets/znca_ios_3.4.1/whitebox/tools/gen_pointer_fixups.py`
- `targets/znca_ios_3.4.1/whitebox/tools/gen_layout_segments.py`
- `targets/znca_ios_3.4.1/whitebox/tools/scan_gen_audio_h_refs.py`
- `targets/znca_ios_3.4.1/whitebox/analysis/pointer_fixup_per_4k.npy`
- `targets/znca_ios_3.4.1/whitebox/analysis/pointer_fixup_mask.npy`
- `targets/znca_ios_3.4.1/whitebox/analysis/pointer_fixup_targets.npy`
- `targets/znca_ios_3.4.1/whitebox/analysis/gen_audio_h_data_refs.pkl`
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Build a rebase-resolved flat memory image of Crew for emulation.
Reads raw file bytes, then for every dyld chained-fixup rebase relocation,
overwrites the 8-byte pointer slot with the fully resolved absolute VA
(lief already resolves target to an absolute VA for rebase entries).
"""
import struct
import lief
from pathlib import Path
BIN_PATH = "/home/vscode/app/targets/znca_ios_3.4.1/Crew"
# Import (bind) symbol name (without leading underscore) -> address we want
# GOT/bind slots pointing at that symbol to resolve to. If the symbol matches
# a known __stubs trampoline used elsewhere in emulate.py's STUBS table, point
# directly at that stub VA so indirect calls (blr via a GOT-loaded pointer)
# land on our hook too. Populated lazily by emulate.py via set_stub_table().
_stub_name_to_addr = {}
def set_stub_table(stubs_dict):
"""stubs_dict: {va: name} as used by emulate.py STUBS."""
global _stub_name_to_addr
_stub_name_to_addr = {name: va for va, name in stubs_dict.items()}
FAKEBIND_BASE = 0x00007FB000000000
FAKEBIND_SIZE = 0x00200000
_cache = {}
def get_patched_bytes():
if "buf" in _cache:
return _cache["buf"], _cache["segments"]
raw = bytearray(Path(BIN_PATH).read_bytes())
b = lief.parse(BIN_PATH)
segments = []
for seg in b.segments:
segments.append((seg.virtual_address, seg.virtual_size, seg.file_offset, seg.name))
def va_to_off(va):
for vaddr, vsize, foff, name in segments:
if vaddr <= va < vaddr + vsize:
return foff + (va - vaddr)
return None
n_patched = 0
n_skipped = 0
for r in b.relocations:
t = r.target
if not isinstance(t, int):
n_skipped += 1
continue
off = va_to_off(r.address)
if off is None or off + 8 > len(raw):
n_skipped += 1
continue
struct.pack_into("<Q", raw, off, t & 0xFFFFFFFFFFFFFFFF)
n_patched += 1
print(f"[build_image] patched {n_patched} rebase slots, skipped {n_skipped}")
# --- patch BIND (import) slots ---
dcf = b.dyld_chained_fixups
fakebind_buf = bytearray(FAKEBIND_SIZE)
fakebind_ptr = 0x10 # leave a null cell at offset 0
name_to_cell = {}
n_bind_stub = 0
n_bind_data = 0
n_bind_skip = 0
bind_map = {} # address(int) -> resolved value written (for debugging/report)
for bd in dcf.bindings:
sym = bd.symbol
if sym is None:
n_bind_skip += 1
continue
raw_name = sym.name or ""
short = raw_name[1:] if raw_name.startswith("_") else raw_name
off = va_to_off(bd.address)
if off is None or off + 8 > len(raw):
n_bind_skip += 1
continue
if short in _stub_name_to_addr:
val = _stub_name_to_addr[short]
n_bind_stub += 1
else:
if raw_name not in name_to_cell:
cell_va = FAKEBIND_BASE + fakebind_ptr
name_to_cell[raw_name] = cell_va
fakebind_ptr += 8
assert fakebind_ptr < FAKEBIND_SIZE
val = name_to_cell[raw_name]
n_bind_data += 1
struct.pack_into("<Q", raw, off, val & 0xFFFFFFFFFFFFFFFF)
bind_map[bd.address] = (raw_name, val)
print(f"[build_image] bind slots: stub-redirected={n_bind_stub} data-fake={n_bind_data} skipped={n_bind_skip}")
_cache["buf"] = bytes(raw)
_cache["segments"] = segments
_cache["fakebind_buf"] = bytes(fakebind_buf)
_cache["fakebind_base"] = FAKEBIND_BASE
_cache["fakebind_size"] = FAKEBIND_SIZE
_cache["bind_map"] = bind_map
_cache["cell_to_name"] = {cell_va: name for name, cell_va in name_to_cell.items()}
return _cache["buf"], segments
def get_fakebind_region():
get_patched_bytes()
return _cache["fakebind_base"], _cache["fakebind_size"], _cache["fakebind_buf"]
def get_bind_map():
get_patched_bytes()
return _cache["bind_map"]
def get_cell_map():
"""{fakebind_cell_va: raw_symbol_name} for every *unresolved* bind symbol
(i.e. not redirected to a known STUBS entry). Used by emulate.py to
install a generic catch-all hook at every such cell so direct calls
through unresolved lazy-bind GOT slots (e.g. plain libc functions we
didn't explicitly model) don't crash into unmapped/zeroed fakebind
memory."""
get_patched_bytes()
return dict(_cache["cell_to_name"])
if __name__ == "__main__":
buf, segs = get_patched_bytes()
print(f"total image bytes: {len(buf)}")
for s in segs:
print(f" va=0x{s[0]:x} size=0x{s[1]:x} foff=0x{s[2]:x} name={s[3]}")
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Common helpers: va<->file offset mapping via lief, capstone disassembler."""
import lief
import capstone
from pathlib import Path
BIN_PATH = "/home/vscode/app/targets/znca_ios_3.4.1/Crew"
_binary = None
_segments = None
def get_binary():
global _binary
if _binary is None:
_binary = lief.parse(BIN_PATH)
return _binary
def get_segments():
global _segments
if _segments is None:
b = get_binary()
segs = []
for seg in b.segments:
segs.append((seg.virtual_address, seg.virtual_size, seg.file_offset, seg.name))
_segments = segs
return _segments
def va_to_off(va):
for vaddr, vsize, foff, name in get_segments():
if vaddr <= va < vaddr + vsize:
return foff + (va - vaddr)
return None
def get_section_for_va(va):
b = get_binary()
for seg in b.segments:
if seg.virtual_address <= va < seg.virtual_address + seg.virtual_size:
for sec in seg.sections:
if sec.virtual_address <= va < sec.virtual_address + sec.size:
return f"{seg.name}.{sec.name}"
return f"{seg.name}.<none>"
return None
_raw = None
def get_raw():
global _raw
if _raw is None:
_raw = Path(BIN_PATH).read_bytes()
return _raw
def read_bytes(va, size):
off = va_to_off(va)
if off is None:
return None
return get_raw()[off:off+size]
def cstr_at(va, maxlen=256):
off = va_to_off(va)
if off is None:
return None
raw = get_raw()
end = raw.find(b'\x00', off, off+maxlen)
if end == -1:
end = off + maxlen
return raw[off:end]
def make_cs():
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
return md
def disasm_range(start_va, end_va):
"""Return list of capstone insns for [start_va, end_va)."""
data = read_bytes(start_va, end_va - start_va)
md = make_cs()
return list(md.disasm(data, start_va))
def shannon_entropy(data):
import math
if not data:
return 0.0
from collections import Counter
c = Counter(data)
n = len(data)
ent = 0.0
for v in c.values():
p = v / n
ent -= p * math.log2(p)
return ent
def printable_ratio(data):
if not data:
return 0.0
cnt = sum(1 for b in data if 0x20 <= b < 0x7f)
return cnt / len(data)
if __name__ == "__main__":
print("segments:")
for s in get_segments():
print(f" va=0x{s[0]:x} size=0x{s[1]:x} foff=0x{s[2]:x} name={s[3]}")
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Disassemble the initGenAudioH body [0x100783fc8, 0x1007d594c) and dump to text."""
from common import disasm_range
START = 0x100783fc8
END = 0x1007d594c
def main():
insns = disasm_range(START, END)
out_path = "/tmp/initgenaudioh_body.txt"
with open(out_path, "w") as f:
for i in insns:
f.write(f"0x{i.address:09x}: {i.mnemonic}\t{i.op_str}\n")
print(f"wrote {len(insns)} insns to {out_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from common import disasm_range
START = 0x1007cec00
END = 0x1007cf200
for i in disasm_range(START, END):
print(f"0x{i.address:09x}: {i.mnemonic}\t{i.op_str}")
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from common import disasm_range
START = 0x100a29528
END = 0x100a29528 + 0x7c
for i in disasm_range(START, END):
print(f"0x{i.address:09x}: {i.mnemonic}\t{i.op_str}")
@@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""Unicorn-based emulation of +[VoIPClient initGenAudioH] body (0x100783fc8..0x1007d594c)
to dynamically resolve CFF-obfuscated anti-debug strings and syscall arguments.
Strategy: map a rebase-resolved flat image of Crew, hook every imported stub
call site (bl targets in __TEXT.__stubs) with a Python handler that logs args
and fakes a "clean device" return, and hook raw SVC (syscall) instructions to
fake syscall results consistent with a non-jailbroken / non-debugged host so
that execution proceeds through as much of the anti-debug gauntlet as possible.
"""
import struct
import sys
from unicorn import *
from unicorn.arm64_const import *
from build_image import get_patched_bytes, set_stub_table, get_fakebind_region, get_cell_map
BASE = 0x100000000
FUNC_START = 0x100783fc8
FUNC_END = 0x1007d594c
STACK_BASE = 0x00007FF000000000
STACK_SIZE = 0x800000 # 8 MiB
HEAP_BASE = 0x00007FE000000000
HEAP_SIZE = 0x02000000 # 32 MiB
FAKE_BASE = 0x00007FD000000000 # misc fake string / struct pool
FAKE_SIZE = 0x00100000
SENTINEL_PAGE = 0x00007FC0DEAD0000 & ~0xFFF
SENTINEL_RET = 0x00007FC0DEAD0000
STUBS = {
0x100c32fe8: "CFCopyHomeDirectoryURL",
0x100c33084: "CFRelease",
0x100c330a8: "CFStringGetCString",
0x100c330b4: "CFURLCopyFileSystemPath",
0x100c33648: "_Unwind_Resume",
0x100c3390c: "recursive_mutex_lock",
0x100c33918: "recursive_mutex_unlock",
0x100c3396c: "mutex_lock",
0x100c33978: "mutex_unlock",
0x100c33ad4: "operator_delete",
0x100c33af8: "operator_new_array",
0x100c33b04: "operator_new",
0x100c33bc4: "__error",
0x100c33c24: "__snprintf_chk",
0x100c33c30: "__stack_chk_fail",
0x100c33c60: "_dyld_get_image_header",
0x100c33c6c: "_dyld_get_image_name",
0x100c33c78: "_dyld_get_image_vmaddr_slide",
0x100c33c84: "_dyld_image_count",
0x100c33e04: "clock_gettime",
0x100c33e1c: "closedir",
0x100c33ff0: "dladdr",
0x100c33ffc: "dlclose",
0x100c34008: "dlopen",
0x100c34014: "dlsym",
0x100c34110: "getenv",
0x100c3426c: "malloc",
0x100c342b4: "memset",
0x100c343f8: "objc_getClass",
0x100c34434: "objc_msgSend",
0x100c346d4: "opendir",
0x100c347d0: "pthread_create",
0x100c3480c: "pthread_join",
0x100c34920: "rand",
0x100c34944: "readdir",
0x100c34980: "sel_registerName",
0x100c34b84: "sranddev",
0x100c353dc: "uuid_generate",
}
# Internal (non-imported) helper functions that construct C++ objects with
# externally-bound (libc++) vtables. We don't have real libc++ loaded, so
# virtual dispatch through their vtables lands in fake/zeroed memory and
# crashes emulation. These are unrelated to the anti-debug string logic we
# care about, so short-circuit them like stubs (fake "constructed object"
# pointer return).
INTERNAL_SKIP = {
0x1007d594c: "compound_init_ctor",
}
SYSCALLS = {
4: "write", 5: "open", 6: "close", 10: "unlink", 20: "getpid", 26: "ptrace",
33: "access", 37: "kill", 57: "symlink", 202: "__sysctl",
}
FAKE_PID = 1337
class Emu:
def __init__(self, log_path="/tmp/emu_log.txt"):
self.uc = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
self.heap_ptr = HEAP_BASE
self.fake_ptr = FAKE_BASE
self.log_f = open(log_path, "w")
self.call_log = []
self.stop_reason = None
self.rand_counter = 0x1234
def log(self, msg):
print(msg)
self.log_f.write(msg + "\n")
self.log_f.flush()
def read_cstr(self, addr, maxlen=512):
if addr == 0:
return None
try:
data = self.uc.mem_read(addr, maxlen)
except UcError:
return None
end = bytes(data).find(b"\x00")
if end == -1:
end = maxlen
raw = bytes(data[:end])
return raw
def bump_heap(self, size):
size = (size + 0xF) & ~0xF
p = self.heap_ptr
self.heap_ptr += size
assert self.heap_ptr < HEAP_BASE + HEAP_SIZE, "heap exhausted"
return p
def bump_fake(self, data):
p = self.fake_ptr
self.uc.mem_write(p, data)
self.fake_ptr += (len(data) + 0xF) & ~0xF
assert self.fake_ptr < FAKE_BASE + FAKE_SIZE, "fake pool exhausted"
return p
def setup_memory(self):
set_stub_table(STUBS)
buf, segs = get_patched_bytes()
fb_base, fb_size, fb_buf = get_fakebind_region()
seg_map = {name: (va, sz, off) for (va, sz, off, name) in segs}
for name in ("__TEXT", "__DATA_CONST", "__DATA", "__ETC"):
va, sz, off = seg_map[name]
va_aligned = va & ~0xFFF
end_aligned = (va + sz + 0xFFF) & ~0xFFF
map_size = end_aligned - va_aligned
self.uc.mem_map(va_aligned, map_size, UC_PROT_ALL)
self.uc.mem_write(va, buf[off:off+sz])
self.log(f"[map] {name} va=0x{va:x} size=0x{sz:x}")
self.uc.mem_map(STACK_BASE, STACK_SIZE, UC_PROT_ALL)
self.uc.mem_map(HEAP_BASE, HEAP_SIZE, UC_PROT_ALL)
self.uc.mem_map(FAKE_BASE, FAKE_SIZE, UC_PROT_ALL)
self.uc.mem_map(fb_base, fb_size, UC_PROT_ALL)
self.uc.mem_write(fb_base, fb_buf)
self.log(f"[map] __FAKEBIND va=0x{fb_base:x} size=0x{fb_size:x}")
self.uc.mem_map(SENTINEL_PAGE, 0x1000, UC_PROT_ALL)
self.uc.mem_write(SENTINEL_RET, bytes.fromhex("000020d4")) # brk #0
self.log(f"[map] SENTINEL va=0x{SENTINEL_RET:x}")
# pre-seed some fake strings we may need pointers to
self.fake_image_path = self.bump_fake(
b"/private/var/containers/Bundle/Application/AAAA/Crew.app/Crew\x00")
self.fake_home_path = self.bump_fake(b"/var/mobile\x00")
self.errno_slot = self.bump_fake(struct.pack("<i", 0))
def set_ret(self, val):
self.uc.reg_write(UC_ARM64_REG_X0, val & 0xFFFFFFFFFFFFFFFF)
def do_ret(self):
lr = self.uc.reg_read(UC_ARM64_REG_LR)
self.uc.reg_write(UC_ARM64_REG_PC, lr)
def get_x(self, n):
return self.uc.reg_read(getattr(unicorn.arm64_const, f"UC_ARM64_REG_X{n}"))
def hook_generic_fakebind(self, uc, address, size, user_data):
"""Catch-all for direct calls through *unresolved* lazy-bind GOT
slots (plain libc/C++ symbols we didn't explicitly model in STUBS).
Logs the real symbol name (from build_image's bind table) and
returns a safe default (0) so execution can keep going instead of
crashing into zeroed fakebind memory."""
name = self._cell_map.get(address, f"?cell@0x{address:x}")
x = [uc.reg_read(getattr(sys.modules['unicorn.arm64_const'], f"UC_ARM64_REG_X{i}")) for i in range(4)]
lr = uc.reg_read(UC_ARM64_REG_LR)
self.log(f"CALL <unmodeled-external> {name} @0x{address:x} lr=0x{lr:x} x0=0x{x[0]:x} x1=0x{x[1]:x} x2=0x{x[2]:x} x3=0x{x[3]:x} -> ret=0x0 (generic fallback)")
self.call_log.append((f"unmodeled:{name}", address, x, 0))
self.set_ret(0)
self.do_ret()
def hook_stub(self, uc, address, size, user_data):
name = STUBS.get(address) or INTERNAL_SKIP.get(address)
if name is None:
return
x = [uc.reg_read(getattr(sys.modules['unicorn.arm64_const'], f"UC_ARM64_REG_X{i}")) for i in range(8)]
handler = getattr(self, f"stub_{name}", None)
entry = f"CALL {name} @0x{address:x} lr=0x{uc.reg_read(UC_ARM64_REG_LR):x} x0=0x{x[0]:x} x1=0x{x[1]:x} x2=0x{x[2]:x} x3=0x{x[3]:x}"
try:
if handler:
ret = handler(uc, x)
else:
ret = 0
except Exception as e:
self.log(f"[stub-error] {name}: {e}")
ret = 0
self.call_log.append((name, address, x, ret))
self.log(entry + f" -> ret=0x{ret if ret is not None else 0:x}")
if ret is not None:
self.set_ret(ret)
self.do_ret()
# --- stub implementations ---
def stub_getenv(self, uc, x):
s = self.read_cstr(x[0])
self.log(f" getenv(\"{s}\")")
return 0 # not set -> NULL
def stub_dlopen(self, uc, x):
s = self.read_cstr(x[0])
self.log(f" dlopen(\"{s}\", mode=0x{x[1]:x})")
return 0 # NULL handle
def stub_dlsym(self, uc, x):
s = self.read_cstr(x[1])
self.log(f" dlsym(handle=0x{x[0]:x}, \"{s}\")")
return 0
def stub_dlclose(self, uc, x):
return 0
def stub_dladdr(self, uc, x):
# Dl_info { const char*dli_fname; void*dli_fbase; const char*dli_sname; void*dli_saddr; }
info_ptr = x[1]
try:
uc.mem_write(info_ptr, struct.pack("<QQQQ", self.fake_image_path, BASE, 0, 0))
except UcError:
pass
return 1
def stub_opendir(self, uc, x):
s = self.read_cstr(x[0])
self.log(f" opendir(\"{s}\")")
return 0xDEAD0001 # non-null sentinel DIR*
def stub_readdir(self, uc, x):
return 0 # NULL -> end of directory immediately
def stub_closedir(self, uc, x):
return 0
def stub__dyld_image_count(self, uc, x):
return 1
def stub__dyld_get_image_name(self, uc, x):
return self.fake_image_path
def stub__dyld_get_image_header(self, uc, x):
return BASE
def stub__dyld_get_image_vmaddr_slide(self, uc, x):
return 0
def stub_malloc(self, uc, x):
size = x[0]
p = self.bump_heap(max(size, 8))
return p
def stub_memset(self, uc, x):
ptr, val, size = x[0], x[1] & 0xFF, x[2]
if size and ptr:
try:
uc.mem_write(ptr, bytes([val]) * size)
except UcError:
pass
return ptr
def stub_objc_getClass(self, uc, x):
s = self.read_cstr(x[0])
self.log(f" objc_getClass(\"{s}\")")
return 0
def stub_objc_msgSend(self, uc, x):
return 0
def stub_sel_registerName(self, uc, x):
s = self.read_cstr(x[0])
self.log(f" sel_registerName(\"{s}\")")
return x[0]
def stub_pthread_create(self, uc, x):
self.log(f" pthread_create(thread=0x{x[0]:x}, attr=0x{x[1]:x}, start_routine=0x{x[2]:x}, arg=0x{x[3]:x})")
return 0
def stub_pthread_join(self, uc, x):
return 0
def stub_clock_gettime(self, uc, x):
ts_ptr = x[1]
try:
uc.mem_write(ts_ptr, struct.pack("<qq", 1000, 0))
except UcError:
pass
return 0
def stub_rand(self, uc, x):
self.rand_counter = (self.rand_counter * 1103515245 + 12345) & 0x7FFFFFFF
return self.rand_counter
def stub_sranddev(self, uc, x):
return None
def stub_uuid_generate(self, uc, x):
ptr = x[0]
try:
uc.mem_write(ptr, bytes(range(16)))
except UcError:
pass
return None
def stub___error(self, uc, x):
return self.errno_slot
def stub___snprintf_chk(self, uc, x):
return 0
def stub___stack_chk_fail(self, uc, x):
self.log(" !!! __stack_chk_fail triggered !!!")
self.stop_reason = "stack_chk_fail"
uc.emu_stop()
return None
def stub_mutex_lock(self, uc, x):
return 0
def stub_mutex_unlock(self, uc, x):
return 0
def stub_recursive_mutex_lock(self, uc, x):
return 0
def stub_recursive_mutex_unlock(self, uc, x):
return 0
def stub_operator_new(self, uc, x):
return self.bump_heap(max(x[0], 8))
def stub_operator_new_array(self, uc, x):
return self.bump_heap(max(x[0], 8))
def stub_operator_delete(self, uc, x):
return None
def stub_CFCopyHomeDirectoryURL(self, uc, x):
return 0xCFCF0001
def stub_CFRelease(self, uc, x):
return None
def stub_CFStringGetCString(self, uc, x):
buf, bufsize = x[1], x[2]
s = self.fake_home_path_str = b"/var/mobile\x00"
try:
uc.mem_write(buf, s[:bufsize])
except UcError:
pass
return 1
def stub_CFURLCopyFileSystemPath(self, uc, x):
return 0xCFCF0002
def stub_compound_init_ctor(self, uc, x):
self.log(f" [skipped internal C++ ctor 0x1007d594c to avoid external-vtable dispatch crash]")
return self.bump_heap(0x40)
def stub__Unwind_Resume(self, uc, x):
self.log(" !!! _Unwind_Resume hit (unexpected exception path) !!!")
self.stop_reason = "unwind_resume"
uc.emu_stop()
return None
# --- SVC / raw syscall handling ---
def hook_intr(self, uc, intno, user_data):
try:
self._hook_intr_impl(uc, intno, user_data)
except Exception as e:
import traceback
self.log(f"[hook_intr EXCEPTION] intno={intno} pc=0x{uc.reg_read(UC_ARM64_REG_PC):x} {e}")
traceback.print_exc(file=self.log_f)
self.log_f.flush()
self.stop_reason = f"hook_intr_exception: {e}"
uc.emu_stop()
def _hook_intr_impl(self, uc, intno, user_data):
pc = uc.reg_read(UC_ARM64_REG_PC)
if pc == SENTINEL_RET:
self.log("[stop] reached SENTINEL return address -> normal function return")
self.stop_reason = "normal_return"
uc.emu_stop()
return
fb_base, fb_size, _ = get_fakebind_region()
if fb_base <= pc < fb_base + fb_size:
self.log(f"[stop] PC landed in __FAKEBIND (external vtable dispatch through unresolved bind) pc=0x{pc:x}")
self.stop_reason = f"landed_in_fakebind pc=0x{pc:x}"
uc.emu_stop()
return
# PC at time of SVC intr callback in unicorn2 == address of the svc insn itself
x16 = uc.reg_read(UC_ARM64_REG_X16)
name = SYSCALLS.get(x16, f"syscall_{x16}")
x0 = uc.reg_read(UC_ARM64_REG_X0)
x1 = uc.reg_read(UC_ARM64_REG_X1)
x2 = uc.reg_read(UC_ARM64_REG_X2)
x3 = uc.reg_read(UC_ARM64_REG_X3)
x4 = uc.reg_read(UC_ARM64_REG_X4)
x5 = uc.reg_read(UC_ARM64_REG_X5)
ret = 0
carry = 0 # success by default
if name == "getpid":
ret = FAKE_PID
elif name == "ptrace":
self.log(f"[SVC@0x{pc:x}] ptrace(request={x0}, pid=0x{x1:x}, addr=0x{x2:x}, data=0x{x3:x})")
ret = 0
elif name == "kill":
self.log(f"[SVC@0x{pc:x}] kill(pid=0x{x0:x}, sig={x1})")
if x1 == 0:
# sig==0 is a liveness probe (no signal sent); treat as success, keep going
ret = 0
else:
self.stop_reason = f"kill(pid=0x{x0:x}, sig={x1})"
self.log(f" !!! self-kill triggered: pid=0x{x0:x} sig={x1} !!!")
uc.emu_stop()
return
elif name == "open":
s = self.read_cstr(x0)
self.log(f"[SVC@0x{pc:x}] open(\"{s}\", flags=0x{x1:x}, mode=0x{x2:x})")
ret = 2 # ENOENT
carry = 1
elif name == "access":
s = self.read_cstr(x0)
self.log(f"[SVC@0x{pc:x}] access(\"{s}\", mode=0x{x1:x})")
ret = 2 # ENOENT
carry = 1
elif name == "unlink":
s = self.read_cstr(x0)
self.log(f"[SVC@0x{pc:x}] unlink(\"{s}\")")
ret = 2
carry = 1
elif name == "symlink":
s0 = self.read_cstr(x0)
s1 = self.read_cstr(x1)
self.log(f"[SVC@0x{pc:x}] symlink(\"{s0}\", \"{s1}\")")
ret = 1 # EPERM
carry = 1
elif name == "close":
ret = 0
elif name == "write":
s = None
try:
s = bytes(uc.mem_read(x1, min(x2, 256)))
except UcError:
pass
self.log(f"[SVC@0x{pc:x}] write(fd={x0}, buf={s!r}, len={x2})")
ret = x2
elif name == "__sysctl":
mib_words = []
try:
miblen = x1
raw = uc.mem_read(x0, miblen * 4)
mib_words = list(struct.unpack(f"<{miblen}i", raw))
except UcError:
pass
self.log(f"[SVC@0x{pc:x}] __sysctl(mib={mib_words}, miblen={x1}, oldp=0x{x2:x}, oldlenp=0x{x3:x}, newp=0x{x4:x}, newlen={x5})")
if x3 != 0:
try:
if x2 == 0:
# size query: pretend the structure is 0x288 bytes (kinfo_proc-ish)
uc.mem_write(x3, struct.pack("<Q", 0x288))
else:
oldlen = struct.unpack("<Q", uc.mem_read(x3, 8))[0]
if oldlen and oldlen < 0x10000:
uc.mem_write(x2, b"\x00" * oldlen)
except UcError:
pass
ret = 0
else:
self.log(f"[SVC@0x{pc:x}] unhandled syscall {name} ({x16}) x0=0x{x0:x} x1=0x{x1:x} x2=0x{x2:x}")
ret = 0
uc.reg_write(UC_ARM64_REG_X0, ret & 0xFFFFFFFFFFFFFFFF)
nzcv = uc.reg_read(UC_ARM64_REG_NZCV)
if carry:
nzcv |= (1 << 29)
else:
nzcv &= ~(1 << 29)
uc.reg_write(UC_ARM64_REG_NZCV, nzcv)
# advance past the svc instruction (4 bytes)
uc.reg_write(UC_ARM64_REG_PC, pc + 4)
def hook_progress(self, uc, address, size, user_data):
self.instr_count += 1
if self.instr_count % 5_000_000 == 0:
self.log(f"[progress] {self.instr_count} instructions executed, pc=0x{address:x}")
def run(self, max_instructions=0, timeout_us=600_000_000):
self.setup_memory()
self.instr_count = 0
for addr in list(STUBS) + list(INTERNAL_SKIP):
self.uc.hook_add(UC_HOOK_CODE, self.hook_stub, begin=addr, end=addr)
self._cell_map = get_cell_map()
fb_base, fb_size, _ = get_fakebind_region()
self.log(f"[setup] {len(self._cell_map)} unmodeled external bind symbols; installing ONE ranged fallback hook over __FAKEBIND [0x{fb_base:x}, 0x{fb_base+fb_size:x})")
self.uc.hook_add(UC_HOOK_CODE, self.hook_generic_fakebind, begin=fb_base, end=fb_base + fb_size - 1)
self.uc.hook_add(UC_HOOK_CODE, self.hook_progress, begin=FUNC_START, end=FUNC_END)
self.uc.hook_add(UC_HOOK_INTR, self.hook_intr)
sp0 = STACK_BASE + STACK_SIZE - 0x1000
self.uc.reg_write(UC_ARM64_REG_SP, sp0)
self.uc.reg_write(UC_ARM64_REG_X0, 0x00007FC000000001) # fake self
self.uc.reg_write(UC_ARM64_REG_X1, 0x00007FC000000002) # fake _cmd
self.uc.reg_write(UC_ARM64_REG_LR, SENTINEL_RET) # sentinel return addr
try:
# until = unreachable address; real stop comes from SENTINEL_RET / kill / stack_chk_fail / timeout
self.uc.emu_start(FUNC_START, 0xFFFFFFFFFFFFFFFF, timeout=timeout_us, count=max_instructions)
except UcError as e:
self.stop_reason = f"UcError: {e}"
self.log(f"[stop] {self.stop_reason} pc=0x{self.uc.reg_read(UC_ARM64_REG_PC):x}")
else:
pc = self.uc.reg_read(UC_ARM64_REG_PC)
if pc == SENTINEL_RET and self.stop_reason is None:
self.stop_reason = "normal_return"
self.log(f"[stop] pc=0x{pc:x} reason={self.stop_reason} instr_count={self.instr_count}")
if __name__ == "__main__":
e = Emu()
e.run()
print("=== call summary ===")
for name, addr, x, ret in e.call_log:
print(f"{name}@0x{addr:x} x0=0x{x[0]:x} ret=0x{ret if ret else 0:x}")
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Find backward (address-decreasing) branch targets within the function range,
which indicate genuine loops (as opposed to forward CFF dispatch)."""
from common import disasm_range
START = 0x100783fc8
END = 0x1007d594c
BRANCH_MNEM = {"b", "b.eq", "b.ne", "b.lt", "b.le", "b.gt", "b.ge", "b.lo",
"b.hs", "b.hi", "b.ls", "b.mi", "b.pl", "b.vs", "b.vc",
"cbz", "cbnz", "tbz", "tbnz", "bl"}
def main():
insns = disasm_range(START, END)
loops = []
for i in insns:
mnem = i.mnemonic
if mnem not in BRANCH_MNEM:
continue
# operand: for b/bl it's just target; for cbz/cbnz it's reg,target; for tbz/tbnz reg,bit,target
parts = i.op_str.split(",")
target_str = parts[-1].strip()
if not target_str.startswith("#0x") and not target_str.startswith("0x"):
continue
try:
target = int(target_str.replace("#", ""), 16)
except ValueError:
continue
if START <= target < i.address:
dist = i.address - target
loops.append((i.address, mnem, i.op_str, target, dist))
loops.sort(key=lambda t: t[4])
print(f"total backward branches: {len(loops)}")
for addr, mnem, op, target, dist in loops:
if dist <= 0x80:
print(f"0x{addr:09x}: {mnem} {op} <- target 0x{target:09x} dist=0x{dist:x}")
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Static scan: find every `svc #0x80` in the initGenAudioH body, identify the
syscall number via the nearest preceding `movz/mov w16,#imm` (or via a
constant assigned to x16/w16 through mov reg,reg -- best effort), and dump
the surrounding ~12 instructions before each SVC to look for movz #imm
literals feeding x0-x5 (candidate MIB words / signal numbers)."""
from common import disasm_range
START = 0x100783fc8
END = 0x1007d594c
def main():
insns = list(disasm_range(START, END))
addr_to_idx = {ins.address: i for i, ins in enumerate(insns)}
svc_sites = [ins for ins in insns if ins.mnemonic == "svc"]
print(f"total svc instructions in range: {len(svc_sites)}")
print()
for svc in svc_sites:
idx = addr_to_idx[svc.address]
window = insns[max(0, idx - 14):idx]
# find nearest movz/mov to w16/x16 for syscall number
sysno = None
sysno_addr = None
for ins in reversed(window):
if ins.mnemonic in ("movz", "mov") and ins.op_str.startswith(("w16,", "x16,")):
sysno = ins.op_str
sysno_addr = ins.address
break
print(f"=== SVC @ 0x{svc.address:09x} (x16 setup: {sysno_addr and hex(sysno_addr)} -> {sysno}) ===")
for ins in window:
marker = ""
if ins.mnemonic == "movz":
marker = " <-- movz"
print(f" 0x{ins.address:09x}: {ins.mnemonic}\t{ins.op_str}{marker}")
print()
if __name__ == "__main__":
main()
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Build a de-flattened pseudo-CFG for the initGenAudioH body.
Strategy
--------
1. Every CFF dispatch idiom (`find_dispatch.py`) is collapsed: instead of
emitting the ~9-instruction and/eor/mul/br machinery as real control
flow, we directly wire each *state writer* (`resolve_edges.py`) to the
concrete `target_base + constant` address it resolves to. This removes
the opaque indirection entirely for the 701/714 (98.2%) writers whose
value is a compile-time constant.
2. Ordinary control flow (`b`, `b.cond`, fallthrough, `ret`, `brk`) is kept
as-is; it was never obfuscated by the dispatch idiom (the idiom only
disguises *destinations*, not the *presence* of branches).
3. `bl` to internal helper functions and `__stubs` import thunks are kept
as informational edges but do not split the block (execution resumes
right after the call).
4. Leaders (basic-block start addresses) = FUNC_START ∪ resolved dispatch
targets ∪ direct branch targets ∪ instruction-after-unconditional-
transfer (b / br / ret / brk).
5. The 13 unresolved (dynamic-value) state writers are recorded as
`dynamic` edges with target=None -- these are exactly the runtime-
dependent forks (anti-debug decisions etc.) that cannot be resolved
without symbolic/concrete execution, and are reported as such, not
guessed.
Output: pickled graph (cfg.pkl) + DOT file + text edge list.
"""
import pickle
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END
from disasm_body import disasm_range
from find_dispatch import find_dispatch_sites, normalize_slot, parse_ops
from resolve_edges import scan_state_writers
OUT_DIR = Path(__file__).parent
def collect_branches(insns, start, end):
addrs = sorted(a for a in insns if start <= a < end)
idx = {a: i for i, a in enumerate(addrs)}
branches = [] # (addr, kind, target_or_None)
bls = [] # (addr, target)
enders = set() # addresses of block-ending instrs (b, br, ret, brk)
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'b':
ops = ins.op_str.strip()
try:
t = int(ops.lstrip('#'), 0)
except ValueError:
t = None
branches.append((a, 'b', t))
enders.add(a)
elif ins.mnemonic.startswith('b.'):
ops = ins.op_str.strip()
try:
t = int(ops.lstrip('#'), 0)
except ValueError:
t = None
branches.append((a, ins.mnemonic, t))
elif ins.mnemonic == 'bl':
ops = ins.op_str.strip()
try:
t = int(ops.lstrip('#'), 0)
except ValueError:
t = None
bls.append((a, t))
elif ins.mnemonic in ('cbz', 'cbnz', 'tbz', 'tbnz'):
ops = ins.op_str.strip()
# target is the last operand
last = ops.split(',')[-1].strip()
try:
t = int(last.lstrip('#'), 0)
except ValueError:
t = None
branches.append((a, ins.mnemonic, t))
elif ins.mnemonic in ('br',):
enders.add(a)
elif ins.mnemonic == 'ret':
enders.add(a)
elif ins.mnemonic in ('brk', 'svc'):
pass # doesn't necessarily end the block (svc especially)
return addrs, idx, branches, bls, enders
def build(force_recache=False):
insns = disasm_range(FUNC_START, FUNC_END)
addrs, idx, branches, bls, enders = collect_branches(insns, FUNC_START, FUNC_END)
sites = find_dispatch_sites(insns, FUNC_START, FUNC_END)
slot_index = {}
slot_owner_targetbase = {}
for s in sites:
slot = normalize_slot(s['state_src_operand'])
if slot is None:
continue
slot_index.setdefault(slot, []).append(s)
slot_owner_targetbase.setdefault(slot, []).append(s['target_base'])
writers = scan_state_writers(insns, FUNC_START, FUNC_END, slot_index)
dispatch_edges = [] # (write_addr, target, value, slot)
dynamic_edges = [] # (write_addr, slot) -- unresolved
invalid_edges = [] # (write_addr, target, value, slot) -- misaligned/OOB,
# almost certainly a stack-slot reused for unrelated
# data at a point outside this dispatch's live range
for w in writers:
owners = slot_owner_targetbase.get(w['slot'], [])
if not w['resolved']:
dynamic_edges.append((w['write_addr'], w['slot']))
continue
for tb in owners:
target = (tb + w['value']) & 0xFFFFFFFFFFFFFFFF
if target % 4 != 0 or not (FUNC_START <= target < FUNC_END):
invalid_edges.append((w['write_addr'], target, w['value'], w['slot']))
continue
dispatch_edges.append((w['write_addr'], target, w['value'], w['slot']))
# ---- leaders ----
leaders = {FUNC_START}
for a, kind, t in branches:
if t is not None:
leaders.add(t)
# instruction after a b.cond/cbz/etc is always a leader (fallthrough path)
i = idx.get(a)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
for a in enders:
i = idx.get(a)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
for w_addr, target, val, slot in dispatch_edges:
leaders.add(target)
i = idx.get(w_addr)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
for w_addr, slot in dynamic_edges:
i = idx.get(w_addr)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
leaders = sorted(a for a in leaders if FUNC_START <= a < FUNC_END)
# ---- build blocks: leader -> (end_addr_exclusive, out_edges) ----
leader_set = set(leaders)
branch_by_addr = {a: (kind, t) for a, kind, t in branches}
dispatch_by_write = {w_addr: (target, val, slot) for w_addr, target, val, slot in dispatch_edges}
dynamic_by_write = {w_addr: slot for w_addr, slot in dynamic_edges}
blocks = {}
for li, leader in enumerate(leaders):
i = idx[leader]
j = i
out_edges = []
block_end = None
while j < len(addrs):
a = addrs[j]
ins = insns[a]
if a in dispatch_by_write:
target, val, slot = dispatch_by_write[a]
out_edges.append(('cff', target, val, slot))
block_end = a
break
if a in dynamic_by_write:
slot = dynamic_by_write[a]
out_edges.append(('dynamic', None, None, slot))
# dynamic writer doesn't necessarily end the block (execution
# continues into the (unresolvable) dispatch idiom); we stop
# the block here anyway since we can't know the real target.
block_end = a
break
if a in branch_by_addr:
kind, t = branch_by_addr[a]
if kind == 'b':
out_edges.append(('b', t, None, None))
block_end = a
break
else:
out_edges.append((kind, t, None, None))
if j + 1 < len(addrs):
out_edges.append(('fallthrough', addrs[j + 1], None, None))
block_end = a
break
if a in enders: # br (unresolved by us at all -> shouldn't hit if dispatch matched) / ret / brk
if ins.mnemonic == 'ret':
out_edges.append(('ret', None, None, None))
elif ins.mnemonic == 'br':
out_edges.append(('br_unresolved', None, None, None))
block_end = a
break
# check if next addr is itself a leader (implicit fallthrough split)
if j + 1 < len(addrs) and addrs[j + 1] in leader_set:
out_edges.append(('fallthrough', addrs[j + 1], None, None))
block_end = a
break
j += 1
else:
block_end = addrs[-1]
blocks[leader] = {'end': block_end, 'out': out_edges}
graph = {
'leaders': leaders,
'blocks': blocks,
'dispatch_edges': dispatch_edges,
'dynamic_edges': dynamic_edges,
'invalid_edges': invalid_edges,
'sites': sites,
'branches': branches,
'bls': bls,
}
with open(OUT_DIR / 'cfg.pkl', 'wb') as f:
pickle.dump(graph, f)
return graph
if __name__ == '__main__':
g = build()
print(f"leaders (pseudo-BB count): {len(g['leaders'])}")
print(f"dispatch (CFF) resolved edges: {len(g['dispatch_edges'])}")
print(f"dynamic (unresolved) edges: {len(g['dynamic_edges'])}")
print(f"invalid (misaligned/OOB -> stale-slot reuse) edges: {len(g['invalid_edges'])}")
print(f"direct branches (b/b.cond/cbz/tbz): {len(g['branches'])}")
print(f"bl call sites: {len(g['bls'])}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
digraph cfg {
rankdir=TB;
node [shape=box, fontname="monospace", fontsize=9];
n_1007d5510 [label="0x1007d5510"];
n_1007d5510 -> n_1007d5304 [label="cbz", color=blue];
n_1007d5510 -> n_1007d5534 [color=gray];
dynamic_unresolved [shape=diamond, style=filled, fillcolor=orange, label="DYNAMIC\n(unresolved)"];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
digraph cfg {
rankdir=TB;
node [shape=box, fontname="monospace", fontsize=9];
n_1007c9670 [label="0x1007c9670"];
n_1007c9670 -> n_1007c9674 [color=gray];
n_1007c9674 [label="0x1007c9674"];
n_1007c9674 -> n_1007c97e8 [color=black];
n_1007c9684 [label="0x1007c9684"];
n_1007c9684 -> n_1007c9844 [color=black];
n_1007c96c8 [label="0x1007c96c8"];
n_1007c96c8 -> n_1007c6760 [label="b.lt", color=blue];
n_1007c96c8 -> n_1007c96e0 [color=gray];
n_1007c96e0 [label="0x1007c96e0"];
n_1007c96e0 -> n_1007c9860 [color=black];
n_1007c96e4 [label="0x1007c96e4"];
n_1007c96e4 -> n_1007c96f0 [color=gray];
n_1007c96f0 [label="0x1007c96f0"];
n_1007c96f0 -> n_1007c9828 [color=black];
n_1007c96fc [label="0x1007c96fc"];
n_1007c96fc -> n_1007c9738 [label="tbnz", color=blue];
n_1007c96fc -> n_1007c9704 [color=gray];
n_1007c9704 [label="0x1007c9704"];
n_1007c9704 -> n_1007c9880 [color=black];
n_1007c9708 [label="0x1007c9708"];
n_1007c9708 -> n_1007c9604 [color=black];
n_1007c970c [label="0x1007c970c"];
n_1007c970c -> n_1007c9934 [label="cbz", color=blue];
n_1007c970c -> n_1007c9734 [color=gray];
n_1007c9734 [label="0x1007c9734"];
n_1007c9734 -> n_1007c97b0 [color=black];
n_1007c9738 [label="0x1007c9738"];
n_1007c9738 -> n_1007c975c [label="cff state=0", color=red];
n_1007c973c [label="0x1007c973c"];
n_1007c973c -> br_unresolved_1007c973c [color=green];
br_unresolved_1007c973c [shape=doublecircle, label="br_unresolved"];
n_1007c975c [label="0x1007c975c"];
n_1007c975c -> n_1007c6768 [color=black];
n_1007c97a4 [label="0x1007c97a4"];
n_1007c97a4 -> n_1007c970c [label="tbnz", color=blue];
n_1007c97a4 -> n_1007c97ac [color=gray];
n_1007c97ac [label="0x1007c97ac"];
n_1007c97ac -> n_1007c9880 [color=black];
n_1007c97b0 [label="0x1007c97b0"];
n_1007c97b0 -> n_1007c97e0 [label="cff state=0", color=red];
n_1007c97b4 [label="0x1007c97b4"];
n_1007c97b4 -> br_unresolved_1007c97b4 [color=green];
br_unresolved_1007c97b4 [shape=doublecircle, label="br_unresolved"];
n_1007c97dc [label="0x1007c97dc"];
n_1007c97dc -> n_1007c97e0 [color=gray];
n_1007c97e0 [label="0x1007c97e0"];
n_1007c97e0 -> n_1007c9960 [color=black];
n_1007c97e8 [label="0x1007c97e8"];
n_1007c97e8 -> n_1007c980c [label="cff state=0", color=red];
n_1007c97ec [label="0x1007c97ec"];
n_1007c97ec -> br_unresolved_1007c97ec [color=green];
br_unresolved_1007c97ec [shape=doublecircle, label="br_unresolved"];
n_1007c980c [label="0x1007c980c"];
n_1007c980c -> n_1007c96c8 [color=black];
n_1007c9828 [label="0x1007c9828"];
n_1007c9828 -> n_1007c98f8 [label="b.lt", color=blue];
n_1007c9828 -> n_1007c9840 [color=gray];
n_1007c9840 [label="0x1007c9840"];
n_1007c9840 -> n_1007c99a0 [color=black];
n_1007c9844 [label="0x1007c9844"];
n_1007c9844 -> n_1007c9640 [label="b.eq", color=blue];
n_1007c9844 -> n_1007c985c [color=gray];
n_1007c985c [label="0x1007c985c"];
n_1007c985c -> n_1007c9914 [color=black];
n_1007c9860 [label="0x1007c9860"];
n_1007c9860 -> n_1007c97a4 [label="tbnz", color=blue];
n_1007c9860 -> n_1007c987c [color=gray];
n_1007c987c [label="0x1007c987c"];
n_1007c987c -> n_1007c970c [color=black];
n_1007c9880 [label="0x1007c9880"];
n_1007c9880 -> n_1007c9844 [color=black];
n_1007c988c [label="0x1007c988c"];
n_1007c988c -> n_1007c9644 [label="cbz", color=blue];
n_1007c988c -> n_1007c98b0 [color=gray];
n_1007c98b0 [label="0x1007c98b0"];
n_1007c98b0 -> n_1007c96c8 [color=black];
n_1007c98bc [label="0x1007c98bc"];
n_1007c98bc -> n_1007c9610 [color=black];
n_1007c98f8 [label="0x1007c98f8"];
n_1007c98f8 -> n_1007c9828 [color=black];
n_1007c9914 [label="0x1007c9914"];
n_1007c9914 -> n_1007c98bc [label="tbnz", color=blue];
n_1007c9914 -> n_1007c9930 [color=gray];
n_1007c9930 [label="0x1007c9930"];
n_1007c9930 -> n_1007c6740 [color=black];
n_1007c9934 [label="0x1007c9934"];
n_1007c9934 -> n_1007c9948 [color=black];
n_1007c9944 [label="0x1007c9944"];
n_1007c9944 -> n_1007c9948 [color=gray];
n_1007c9948 [label="0x1007c9948"];
n_1007c9948 -> br_unresolved_1007c9948 [color=green];
br_unresolved_1007c9948 [shape=doublecircle, label="br_unresolved"];
n_1007c9950 [label="0x1007c9950"];
n_1007c9950 -> n_1007c9960 [color=gray];
n_1007c9960 [label="0x1007c9960"];
n_1007c9960 -> n_1007c9990 [label="cff state=0", color=red];
n_1007c9964 [label="0x1007c9964"];
n_1007c9964 -> br_unresolved_1007c9964 [color=green];
br_unresolved_1007c9964 [shape=doublecircle, label="br_unresolved"];
n_1007c998c [label="0x1007c998c"];
n_1007c998c -> n_1007c9990 [color=gray];
n_1007c9990 [label="0x1007c9990"];
n_1007c9990 -> n_1007c96fc [label="tbnz", color=blue];
n_1007c9990 -> n_1007c999c [color=gray];
dynamic_unresolved [shape=diamond, style=filled, fillcolor=orange, label="DYNAMIC\n(unresolved)"];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Common helpers for CFF de-flatten analysis of znca iOS 3.4.1 Crew binary."""
import struct
from pathlib import Path
BINARY = Path('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
BASE = 0x100000000
SEGMENTS = [
# (vaddr, vsize, fileoff, filesize, name)
(0x100000000, 0xe54000, 0x0, 0xe54000, '__TEXT'),
(0x100e54000, 0x88000, 0xe54000, 0x88000, '__DATA_CONST'),
(0x100edc000, 0x50c000, 0xedc000, 0x2bc000, '__DATA'),
(0x1013e8000, 0x70000, 0x1198000, 0x70000, '__ETC'),
(0x101458000, 0x138000, 0x1208000, 0x136410, '__LINKEDIT'),
]
_data = None
def data():
global _data
if _data is None:
_data = BINARY.read_bytes()
return _data
def va_to_off(va):
for vaddr, vsize, fileoff, filesize, name in SEGMENTS:
if vaddr <= va < vaddr + vsize:
delta = va - vaddr
if delta < filesize:
return fileoff + delta
return None # in bss / zerofill, no file backing
return None
def read_bytes(va, size):
off = va_to_off(va)
if off is None:
return None
return data()[off:off + size]
def read_u32(va):
b = read_bytes(va, 4)
if b is None or len(b) < 4:
return None
return struct.unpack('<I', b)[0]
def read_i32(va):
b = read_bytes(va, 4)
if b is None or len(b) < 4:
return None
return struct.unpack('<i', b)[0]
def read_u64(va):
b = read_bytes(va, 8)
if b is None or len(b) < 8:
return None
return struct.unpack('<Q', b)[0]
# initGenAudioH body bounds
FUNC_START = 0x100783fc8
FUNC_END = 0x1007d594c # exclusive-ish (next LC_FUNCTION_STARTS entry)
WRAPPER_START = 0x100a29528
WRAPPER_END = 0x100a29528 + 0x7c
CFF_STATE_VAR_MAIN = 0x101034204
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Linear capstone disassembly of the initGenAudioH body (0x100783fc8..0x1007d594c).
ARM64 has a fixed 4-byte instruction width, so a *linear* sweep stays
byte-aligned even across embedded data words (the per-dispatch-site jump
delta that follows each `br` idiom). We exploit that: decode every 4-byte
slot with capstone (skip-invalid=False semantics via CS_MODE_ARM, but treat
CS_ERR entries as raw 4-byte data by manual struct fallback) and cache the
whole instruction stream once so every other script in this directory can
reuse it quickly (pickle cache).
"""
import pickle
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import read_bytes, FUNC_START, FUNC_END
import capstone
CACHE = Path(__file__).parent / 'insns.pkl'
class Insn:
__slots__ = ('addr', 'mnemonic', 'op_str', 'size', 'bytes_')
def __init__(self, addr, mnemonic, op_str, size, bytes_):
self.addr = addr
self.mnemonic = mnemonic
self.op_str = op_str
self.size = size
self.bytes_ = bytes_
def __repr__(self):
return f"0x{self.addr:x}: {self.mnemonic} {self.op_str}"
def disasm_range(start, end, force=False):
if CACHE.exists() and not force:
with open(CACHE, 'rb') as f:
cached_start, cached_end, insns = pickle.load(f)
if cached_start == start and cached_end == end:
return insns
blob = read_bytes(start, end - start)
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = False
insns = {}
addr = start
off = 0
n = len(blob)
while off < n:
chunk = blob[off:off + 4]
if len(chunk) < 4:
break
decoded = list(md.disasm(chunk, addr))
if decoded:
ins = decoded[0]
insns[addr] = Insn(addr, ins.mnemonic, ins.op_str, ins.size, chunk)
else:
# undecodable -> treat as raw data word
word = struct.unpack('<I', chunk)[0]
insns[addr] = Insn(addr, '.word', f'0x{word:x}', 4, chunk)
addr += 4
off += 4
with open(CACHE, 'wb') as f:
pickle.dump((start, end, insns), f)
return insns
if __name__ == '__main__':
insns = disasm_range(FUNC_START, FUNC_END, force=True)
print(f"decoded {len(insns)} 4-byte slots from 0x{FUNC_START:x} to 0x{FUNC_END:x}")
n_word = sum(1 for i in insns.values() if i.mnemonic == '.word')
print(f"undecodable (.word / embedded data) slots: {n_word}")
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Disassemble the small wrapper (0x100a29528..+0x7c) with capstone, to
validate our understanding of the CFF dispatch idiom before tackling the
335 KiB main body."""
import sys
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_bytes, WRAPPER_START, WRAPPER_END
import capstone
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
blob = read_bytes(WRAPPER_START, WRAPPER_END - WRAPPER_START)
for insn in md.disasm(blob, WRAPPER_START):
print(f"0x{insn.address:x}:\t{insn.mnemonic}\t{insn.op_str}")
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Export the de-flattened pseudo-CFG (from build_cfg.py) to graphviz DOT.
Produces:
- cfg_full.dot : the entire de-flattened graph (13k+ nodes; too big
to eyeball, but useful for tooling / `dot -Tsvg`
on a subrange, or grep-based path queries).
- cfg_zone_<reg>.dot : one DOT per stack "zone" base register (x19, x20,
x21, x22, x23, x24, x25, x26, x28), restricted to
blocks whose address falls inside that zone's
observed dispatch-site address span. This is the
closest approximation to "副次ディスパッチ変数ごと
の疑似関数" available, since (unlike the doc's
original hypothesis) there is no small family of
shared global dispatch variables -- see the
accompanying markdown for the correction.
"""
import pickle
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END
from find_dispatch import normalize_slot
OUT_DIR = Path(__file__).parent
def load_graph():
with open(OUT_DIR / 'cfg.pkl', 'rb') as f:
return pickle.load(f)
def node_label(addr):
return f'n_{addr:x}'
def emit_dot(path, blocks, leaders_subset=None):
leaders = leaders_subset if leaders_subset is not None else blocks.keys()
lines = ['digraph cfg {', ' rankdir=TB;', ' node [shape=box, fontname="monospace", fontsize=9];']
for leader in leaders:
b = blocks.get(leader)
if b is None:
continue
lines.append(f' {node_label(leader)} [label="0x{leader:x}"];')
for kind, target, val, slot in b['out']:
if kind == 'cff' and target is not None:
lines.append(f' {node_label(leader)} -> {node_label(target)} '
f'[label="cff state={val}", color=red];')
elif kind in ('b', 'fallthrough') and target is not None:
color = 'black' if kind == 'b' else 'gray'
lines.append(f' {node_label(leader)} -> {node_label(target)} [color={color}];')
elif kind.startswith('b.') or kind in ('cbz', 'cbnz', 'tbz', 'tbnz'):
if target is not None:
lines.append(f' {node_label(leader)} -> {node_label(target)} '
f'[label="{kind}", color=blue];')
elif kind == 'dynamic':
lines.append(f' {node_label(leader)} -> dynamic_unresolved '
f'[label="dyn slot={slot}", color=orange, style=dashed];')
elif kind in ('ret', 'br_unresolved'):
lines.append(f' {node_label(leader)} -> {kind}_{leader:x} [color=green];')
lines.append(f' {kind}_{leader:x} [shape=doublecircle, label="{kind}"];')
lines.append(' dynamic_unresolved [shape=diamond, style=filled, fillcolor=orange, label="DYNAMIC\\n(unresolved)"];')
lines.append('}')
path.write_text('\n'.join(lines))
def compute_zone_spans(sites):
spans = {}
for s in sites:
slot = normalize_slot(s['state_src_operand'])
if slot is None:
continue
base = slot[0]
lo, hi = spans.get(base, (s['br_addr'], s['br_addr']))
spans[base] = (min(lo, s['br_addr']), max(hi, s['br_addr']) + 0x10)
return spans
if __name__ == '__main__':
g = load_graph()
blocks = g['blocks']
emit_dot(OUT_DIR / 'cfg_full.dot', blocks)
print(f"wrote cfg_full.dot with {len(blocks)} nodes")
spans = compute_zone_spans(g['sites'])
for zone, (lo, hi) in sorted(spans.items()):
subset = [l for l in blocks if lo <= l < hi]
emit_dot(OUT_DIR / f'cfg_zone_{zone}.dot', blocks, subset)
print(f"wrote cfg_zone_{zone}.dot with {len(subset)} nodes (span 0x{lo:x}-0x{hi:x}, "
f"{sum(1 for s in g['sites'] if normalize_slot(s['state_src_operand']) and normalize_slot(s['state_src_operand'])[0]==zone)} dispatch sites)")
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Find every occurrence of the CFF dispatch idiom in the initGenAudioH body
and resolve each site's `target_base`.
Two idiom variants observed:
Type A (indirect, with an embedded delta word):
adr xA, #TT
ldrsw xB, [xA]
add xA, xB, xA ; xA = TT + word_at(TT) == target_base
and xC2, xA, xSTATE
mov xD, #2
mul xC2, xC2, xD
eor xA, xA, xSTATE
add xA, xA, xC2 ; xA = target_base + state (a^b + 2*(a&b) == a+b)
br xA
Type B (direct, no indirection word):
adr xA, #TT ; target_base = TT directly
and xC2, xA, xSTATE
mov xD, #2
mul xC2, xC2, xD
eor xA, xA, xSTATE
add xA, xA, xC2
br xA
There is also a decoy/opaque "cancelling sub/add" idiom that is NOT a real
keyed dispatch (target is constant regardless of state); we detect and tag
those separately as `kind='opaque'`.
For each real dispatch site we then walk backward a short window to find the
`ldr xSTATE, [base, #off]` that loaded the state register, to identify which
private state slot (base register + stack offset, or global address) drives
that particular site.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import read_i32, FUNC_START, FUNC_END
from disasm_body import disasm_range
def parse_ops(op_str):
return [o.strip() for o in op_str.split(',')] if op_str else []
def find_dispatch_sites(insns, start, end):
addrs = sorted(a for a in insns if start <= a < end)
idx = {a: i for i, a in enumerate(addrs)}
sites = []
for i, a in enumerate(addrs):
ins = insns[a]
if ins.mnemonic != 'br':
continue
# walk backward: add, eor, mov(#2), mul, and, [ldrsw, add,] adr
if i < 8:
continue
seq = [insns[addrs[i - k]] for k in range(1, 9) if i - k >= 0]
# seq[0] = instr right before br (should be 'add')
m_add2 = seq[0]
m_eor = seq[1]
m_mul = seq[2]
m_movd = seq[3]
m_and = seq[4]
if m_add2.mnemonic != 'add':
continue
if m_eor.mnemonic != 'eor':
continue
if m_mul.mnemonic != 'mul':
continue
if m_movd.mnemonic not in ('mov', 'movz'):
continue
if m_and.mnemonic != 'and':
continue
and_ops = parse_ops(m_and.op_str)
eor_ops = parse_ops(m_eor.op_str)
if len(and_ops) != 3 or len(eor_ops) != 3:
continue
state_reg = and_ops[2]
if eor_ops[2] != state_reg:
continue
# movd should be `mov xD, #2`
movd_ops = parse_ops(m_movd.op_str)
if len(movd_ops) != 2 or movd_ops[1].lstrip('#') != '2':
continue
# now determine type A vs B by looking further back
m_next = seq[5] if len(seq) > 5 else None
m_next2 = seq[6] if len(seq) > 6 else None
kind = None
TT = None
word_val = None
adr_addr = None
if m_next is not None and m_next.mnemonic == 'adr':
kind = 'B'
ops = parse_ops(m_next.op_str)
if len(ops) == 2:
try:
TT = int(ops[1].lstrip('#'), 0)
adr_addr = m_next.addr
except ValueError:
continue
elif (m_next is not None and m_next.mnemonic == 'add' and
m_next2 is not None and m_next2.mnemonic == 'ldrsw'):
# need one more further back for adr (k=6 -> merge-add, k=7 -> ldrsw, k=8 -> adr)
k = 8
if i - k < 0:
continue
m_adr = insns[addrs[i - k]]
if m_adr.mnemonic != 'adr':
continue
kind = 'A'
ops = parse_ops(m_adr.op_str)
if len(ops) == 2:
try:
TT = int(ops[1].lstrip('#'), 0)
adr_addr = m_adr.addr
except ValueError:
continue
else:
continue
if TT is None:
continue
if kind == 'A':
word_val = read_i32(TT)
if word_val is None:
continue
target_base = (TT + word_val) & 0xFFFFFFFFFFFFFFFF
else:
target_base = TT
# search backward from adr_addr for the ldr of state_reg
state_load_addr = None
state_src_operand = None
j = idx[adr_addr] - 1
back_window = 0
while j >= 0 and back_window < 6:
back_a = addrs[j]
back_ins = insns[back_a]
back_ops = parse_ops(back_ins.op_str)
if back_ins.mnemonic in ('ldr', 'ldur') and back_ops and back_ops[0] == state_reg:
state_load_addr = back_a
if len(back_ops) >= 2:
state_src_operand = ','.join(back_ops[1:])
break
j -= 1
back_window += 1
sites.append({
'kind': kind,
'adr_addr': adr_addr,
'br_addr': a,
'TT': TT,
'word_val': word_val,
'target_base': target_base,
'state_reg': state_reg,
'state_load_addr': state_load_addr,
'state_src_operand': state_src_operand,
})
return sites
def normalize_slot(operand):
"""Turn '[x19,#0x278]' into ('x19', 0x278); '[x19]' into ('x19', 0)."""
if operand is None:
return None
op = operand.strip('[]')
parts = [p.strip() for p in op.split(',')]
base = parts[0]
off = 0
if len(parts) > 1 and parts[1].startswith('#'):
try:
off = int(parts[1].lstrip('#'), 0)
except ValueError:
off = None
return (base, off)
if __name__ == '__main__':
insns = disasm_range(FUNC_START, FUNC_END)
sites = find_dispatch_sites(insns, FUNC_START, FUNC_END)
from collections import Counter
kinds = Counter(s['kind'] for s in sites)
print(f"found {len(sites)} dispatch idiom instances: {dict(kinds)}")
unresolved_state = sum(1 for s in sites if s['state_load_addr'] is None)
print(f"sites where state load could not be found in backward window: {unresolved_state}")
slots = Counter(normalize_slot(s['state_src_operand']) for s in sites if s['state_src_operand'])
print(f"distinct state slots referenced by dispatch sites: {len(slots)}")
for slot, cnt in slots.most_common(20):
print(slot, cnt)
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Print the small set of CFF dispatch edges that actually matter:
- the 2 non-zero constant redirects (real long-range unconditional jumps
materialized through the dispatch idiom instead of a plain `b`)
- the 13 dynamic (data-dependent) writers, with their immediate defining
context, which are the only truly runtime-dependent forks routed
through the CFF machinery in this function.
Reproduces the evidence cited in
docs/spec/znca_ios_init_gen_audio_h_cff.md.
"""
import pickle
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END
from disasm_body import disasm_range
with open(Path(__file__).parent / 'cfg.pkl', 'rb') as f:
g = pickle.load(f)
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
idx = {a: i for i, a in enumerate(addrs)}
def dump(addr, before=8, after=0):
i = idx[addr]
for k in range(max(0, i - before), i + 1 + after):
aa = addrs[k]
print(f" 0x{aa:x}: {insns[aa].mnemonic} {insns[aa].op_str}")
print("=== non-zero constant redirects (real jumps, not fallthrough no-ops) ===")
for w, target, val, slot in g['dispatch_edges']:
if val != 0:
print(f"write=0x{w:x} val={val} slot={slot} -> target=0x{target:x}")
dump(w, before=4)
print()
print("=== dynamic (data-dependent) writers ===")
for w, slot in g['dynamic_edges']:
print(f"write=0x{w:x} slot={slot}")
dump(w, before=10)
print()
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Resolve CFF dispatch edges.
For each dispatch idiom site found by find_dispatch.py we know:
- target_base (a fixed VA, computable at rest, "this site's own home
address" essentially -- always target_base == br_addr + 4 for type B,
or (adr TT) + word_at(TT) for type A, which in every sample we saw also
reduces to the instruction immediately following the site's own embedded
word, i.e. target_base is basically "the address physically following
this dispatch instance").
- state_reg, and the exact (base_reg, imm_offset) memory slot that feeds
state_reg for this particular site.
The actual jump target executed at runtime is `target_base + state`, where
`state` was written into that private slot by a `str` instruction reached
earlier along the executed path. We scan the *entire* function body for every
`str`/`stur` that targets one of the known dispatch slots, backward-trace its
source register to a literal constant (mov/movz/movn, optionally
movz+movk pairs, or wzr/xzr), and -- if resolvable -- compute the concrete
edge `write_site -> target_base + constant`.
Writes whose source cannot be reduced to a compile-time constant in a short
backward window are flagged `dynamic` (this is exactly where genuine
runtime-dependent control flow, e.g. anti-debug results, would show up) and
are reported, not guessed.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END
from disasm_body import disasm_range
from find_dispatch import find_dispatch_sites, parse_ops, normalize_slot
MASK64 = (1 << 64) - 1
MASK32 = (1 << 32) - 1
def const_from_mov(ins):
"""Return (width, value) if `ins` is a mov/movz/movn/orr-with-xzr style
constant load, else None. Does not handle movk (caller chains)."""
ops = parse_ops(ins.op_str)
if not ops:
return None
dst = ops[0]
width = 64 if dst.startswith('x') else 32
if ins.mnemonic in ('mov', 'movz'):
if len(ops) == 2:
try:
val = int(ops[1].lstrip('#'), 0)
except ValueError:
# e.g. `mov x8, x9` register-to-register, not constant
if ops[1] in ('xzr', 'wzr'):
return dst, width, 0
return None
return dst, width, val & (MASK64 if width == 64 else MASK32)
elif len(ops) == 3 and 'lsl' in ops[2]:
try:
val = int(ops[1].lstrip('#'), 0)
shift = int(ops[2].split('#')[1].rstrip(')'), 0)
except (ValueError, IndexError):
return None
return dst, width, (val << shift) & (MASK64 if width == 64 else MASK32)
elif ins.mnemonic == 'movn':
if len(ops) == 2:
try:
val = int(ops[1].lstrip('#'), 0)
except ValueError:
return None
inv = (~val) & (MASK64 if width == 64 else MASK32)
return dst, width, inv
elif len(ops) == 3 and 'lsl' in ops[2]:
try:
val = int(ops[1].lstrip('#'), 0)
shift = int(ops[2].split('#')[1].rstrip(')'), 0)
except (ValueError, IndexError):
return None
inv = (~(val << shift)) & (MASK64 if width == 64 else MASK32)
return dst, width, inv
return None
def movk_apply(base_val, width, ins):
ops = parse_ops(ins.op_str)
if ins.mnemonic != 'movk' or len(ops) not in (2, 3):
return None
try:
val = int(ops[1].lstrip('#'), 0)
except ValueError:
return None
shift = 0
if len(ops) == 3 and 'lsl' in ops[2]:
try:
shift = int(ops[2].split('#')[1].rstrip(')'), 0)
except (ValueError, IndexError):
return None
mask = MASK64 if width == 64 else MASK32
clear_mask = ~(0xFFFF << shift) & mask
return (base_val & clear_mask) | ((val << shift) & mask)
def trace_const_backward(insns, addrs, idx, str_addr, src_reg, window=6):
"""Backward-trace src_reg from the instruction at str_addr to find a
constant definition. Returns (value, def_addrs) or None."""
i = idx[str_addr]
j = i - 1
steps = 0
# collect any movk chain first (movk instrs closest to the str, applied
# on top of an earlier movz/mov), then the base mov
movk_chain = []
while j >= 0 and steps < window:
a = addrs[j]
ins = insns[a]
ops = parse_ops(ins.op_str)
if ins.mnemonic == 'movk' and ops and ops[0] == src_reg:
movk_chain.append(ins)
j -= 1
steps += 1
continue
base = const_from_mov(ins)
if base is not None and base[0] == src_reg:
_, width, val = base
def_addrs = [ins.addr]
for mk in reversed(movk_chain):
newval = movk_apply(val, width, mk)
if newval is None:
return None
val = newval
def_addrs.append(mk.addr)
return val, def_addrs
j -= 1
steps += 1
return None
def scan_state_writers(insns, start, end, slot_index):
"""slot_index: dict (base_reg, off) -> list of site dicts.
Returns list of resolved/unresolved writer records."""
addrs = sorted(a for a in insns if start <= a < end)
idx = {a: i for i, a in enumerate(addrs)}
records = []
for a in addrs:
ins = insns[a]
if ins.mnemonic not in ('str', 'stur'):
continue
ops = parse_ops(ins.op_str)
if len(ops) < 2:
continue
src_reg = ops[0]
slot = normalize_slot(','.join(ops[1:]))
if slot is None or slot not in slot_index:
continue
# immediate wzr/xzr write
if src_reg in ('wzr', 'xzr'):
records.append({
'write_addr': a, 'slot': slot, 'value': 0,
'def_addrs': [a], 'resolved': True,
})
continue
res = trace_const_backward(insns, addrs, idx, a, src_reg)
if res is None:
records.append({
'write_addr': a, 'slot': slot, 'value': None,
'def_addrs': [], 'resolved': False,
})
else:
val, def_addrs = res
records.append({
'write_addr': a, 'slot': slot, 'value': val,
'def_addrs': def_addrs, 'resolved': True,
})
return records
if __name__ == '__main__':
insns = disasm_range(FUNC_START, FUNC_END)
sites = find_dispatch_sites(insns, FUNC_START, FUNC_END)
slot_index = {}
for s in sites:
slot = normalize_slot(s['state_src_operand'])
if slot is None:
continue
slot_index.setdefault(slot, []).append(s)
records = scan_state_writers(insns, FUNC_START, FUNC_END, slot_index)
resolved = [r for r in records if r['resolved']]
unresolved = [r for r in records if not r['resolved']]
print(f"total state-slot writers found: {len(records)}")
print(f"resolved (constant) writers: {len(resolved)}")
print(f"unresolved (dynamic-value) writers: {len(unresolved)}")
print("\nsample unresolved writers (first 20):")
for r in unresolved[:20]:
print(f" 0x{r['write_addr']:x} slot={r['slot']}")
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Build a de-flattened pseudo-CFG for the initGenAudioH body.
Strategy
--------
1. Every CFF dispatch idiom (`find_dispatch.py`) is collapsed: instead of
emitting the ~9-instruction and/eor/mul/br machinery as real control
flow, we directly wire each *state writer* (`resolve_edges.py`) to the
concrete `target_base + constant` address it resolves to. This removes
the opaque indirection entirely for the 701/714 (98.2%) writers whose
value is a compile-time constant.
2. Ordinary control flow (`b`, `b.cond`, fallthrough, `ret`, `brk`) is kept
as-is; it was never obfuscated by the dispatch idiom (the idiom only
disguises *destinations*, not the *presence* of branches).
3. `bl` to internal helper functions and `__stubs` import thunks are kept
as informational edges but do not split the block (execution resumes
right after the call).
4. Leaders (basic-block start addresses) = FUNC_START ∪ resolved dispatch
targets ∪ direct branch targets ∪ instruction-after-unconditional-
transfer (b / br / ret / brk).
5. The 13 unresolved (dynamic-value) state writers are recorded as
`dynamic` edges with target=None -- these are exactly the runtime-
dependent forks (anti-debug decisions etc.) that cannot be resolved
without symbolic/concrete execution, and are reported as such, not
guessed.
Output: pickled graph (cfg.pkl) + DOT file + text edge list.
"""
import pickle
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END
from disasm_body import disasm_range
from find_dispatch import find_dispatch_sites, normalize_slot, parse_ops
from resolve_edges import scan_state_writers
OUT_DIR = Path(__file__).parent
def collect_branches(insns, start, end):
addrs = sorted(a for a in insns if start <= a < end)
idx = {a: i for i, a in enumerate(addrs)}
branches = [] # (addr, kind, target_or_None)
bls = [] # (addr, target)
enders = set() # addresses of block-ending instrs (b, br, ret, brk)
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'b':
ops = ins.op_str.strip()
try:
t = int(ops.lstrip('#'), 0)
except ValueError:
t = None
branches.append((a, 'b', t))
enders.add(a)
elif ins.mnemonic.startswith('b.'):
ops = ins.op_str.strip()
try:
t = int(ops.lstrip('#'), 0)
except ValueError:
t = None
branches.append((a, ins.mnemonic, t))
elif ins.mnemonic == 'bl':
ops = ins.op_str.strip()
try:
t = int(ops.lstrip('#'), 0)
except ValueError:
t = None
bls.append((a, t))
elif ins.mnemonic in ('cbz', 'cbnz', 'tbz', 'tbnz'):
ops = ins.op_str.strip()
# target is the last operand
last = ops.split(',')[-1].strip()
try:
t = int(last.lstrip('#'), 0)
except ValueError:
t = None
branches.append((a, ins.mnemonic, t))
elif ins.mnemonic in ('br',):
enders.add(a)
elif ins.mnemonic == 'ret':
enders.add(a)
elif ins.mnemonic in ('brk', 'svc'):
pass # doesn't necessarily end the block (svc especially)
return addrs, idx, branches, bls, enders
def build(force_recache=False):
insns = disasm_range(FUNC_START, FUNC_END)
addrs, idx, branches, bls, enders = collect_branches(insns, FUNC_START, FUNC_END)
sites = find_dispatch_sites(insns, FUNC_START, FUNC_END)
slot_index = {}
slot_owner_targetbase = {}
for s in sites:
slot = normalize_slot(s['state_src_operand'])
if slot is None:
continue
slot_index.setdefault(slot, []).append(s)
slot_owner_targetbase.setdefault(slot, []).append(s['target_base'])
writers = scan_state_writers(insns, FUNC_START, FUNC_END, slot_index)
dispatch_edges = [] # (write_addr, target, value, slot)
dynamic_edges = [] # (write_addr, slot) -- unresolved
invalid_edges = [] # (write_addr, target, value, slot) -- misaligned/OOB,
# almost certainly a stack-slot reused for unrelated
# data at a point outside this dispatch's live range
for w in writers:
owners = slot_owner_targetbase.get(w['slot'], [])
if not w['resolved']:
dynamic_edges.append((w['write_addr'], w['slot']))
continue
for tb in owners:
target = (tb + w['value']) & 0xFFFFFFFFFFFFFFFF
if target % 4 != 0 or not (FUNC_START <= target < FUNC_END):
invalid_edges.append((w['write_addr'], target, w['value'], w['slot']))
continue
dispatch_edges.append((w['write_addr'], target, w['value'], w['slot']))
# ---- leaders ----
leaders = {FUNC_START}
for a, kind, t in branches:
if t is not None:
leaders.add(t)
# instruction after a b.cond/cbz/etc is always a leader (fallthrough path)
i = idx.get(a)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
for a in enders:
i = idx.get(a)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
for w_addr, target, val, slot in dispatch_edges:
leaders.add(target)
i = idx.get(w_addr)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
for w_addr, slot in dynamic_edges:
i = idx.get(w_addr)
if i is not None and i + 1 < len(addrs):
leaders.add(addrs[i + 1])
leaders = sorted(a for a in leaders if FUNC_START <= a < FUNC_END)
# ---- build blocks: leader -> (end_addr_exclusive, out_edges) ----
leader_set = set(leaders)
branch_by_addr = {a: (kind, t) for a, kind, t in branches}
dispatch_by_write = {w_addr: (target, val, slot) for w_addr, target, val, slot in dispatch_edges}
dynamic_by_write = {w_addr: slot for w_addr, slot in dynamic_edges}
blocks = {}
for li, leader in enumerate(leaders):
i = idx[leader]
j = i
out_edges = []
block_end = None
while j < len(addrs):
a = addrs[j]
ins = insns[a]
if a in dispatch_by_write:
target, val, slot = dispatch_by_write[a]
out_edges.append(('cff', target, val, slot))
block_end = a
break
if a in dynamic_by_write:
slot = dynamic_by_write[a]
out_edges.append(('dynamic', None, None, slot))
# dynamic writer doesn't necessarily end the block (execution
# continues into the (unresolvable) dispatch idiom); we stop
# the block here anyway since we can't know the real target.
block_end = a
break
if a in branch_by_addr:
kind, t = branch_by_addr[a]
if kind == 'b':
out_edges.append(('b', t, None, None))
block_end = a
break
else:
out_edges.append((kind, t, None, None))
if j + 1 < len(addrs):
out_edges.append(('fallthrough', addrs[j + 1], None, None))
block_end = a
break
if a in enders: # br (unresolved by us at all -> shouldn't hit if dispatch matched) / ret / brk
if ins.mnemonic == 'ret':
out_edges.append(('ret', None, None, None))
elif ins.mnemonic == 'br':
out_edges.append(('br_unresolved', None, None, None))
block_end = a
break
# check if next addr is itself a leader (implicit fallthrough split)
if j + 1 < len(addrs) and addrs[j + 1] in leader_set:
out_edges.append(('fallthrough', addrs[j + 1], None, None))
block_end = a
break
j += 1
else:
block_end = addrs[-1]
blocks[leader] = {'end': block_end, 'out': out_edges}
graph = {
'leaders': leaders,
'blocks': blocks,
'dispatch_edges': dispatch_edges,
'dynamic_edges': dynamic_edges,
'invalid_edges': invalid_edges,
'sites': sites,
'branches': branches,
'bls': bls,
}
with open(OUT_DIR / 'cfg.pkl', 'wb') as f:
pickle.dump(graph, f)
return graph
if __name__ == '__main__':
g = build()
print(f"leaders (pseudo-BB count): {len(g['leaders'])}")
print(f"dispatch (CFF) resolved edges: {len(g['dispatch_edges'])}")
print(f"dynamic (unresolved) edges: {len(g['dynamic_edges'])}")
print(f"invalid (misaligned/OOB -> stale-slot reuse) edges: {len(g['invalid_edges'])}")
print(f"direct branches (b/b.cond/cbz/tbz): {len(g['branches'])}")
print(f"bl call sites: {len(g['bls'])}")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, '.')
import lief
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END
from collections import Counter
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
print("=== imported symbols matching crypto/hash keywords ===")
for sym in b.imported_symbols:
name = sym.name
low = name.lower()
if any(k in low for k in ('sha', 'hmac', 'aes', 'crypt', 'hash', 'cc_', 'digest')):
print(f" {name}")
print("\n=== __stubs section entries (if resolvable) ===")
for sec in b.sections:
if sec.name in ('__stubs', '__stub_helper'):
print(sec.name, hex(sec.virtual_address), hex(sec.size))
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
bl_targets = Counter()
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'bl':
try:
t = int(ins.op_str.strip().lstrip('#'), 0)
except ValueError:
continue
bl_targets[t] += 1
print(f"\n_gen_audio_h distinct bl targets: {len(bl_targets)}")
for t, c in sorted(bl_targets.items()):
print(f" 0x{t:x} calls={c}")
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Common helpers for CFF de-flatten analysis of _gen_audio_h (znca iOS 3.4.1 Crew)."""
import struct
from pathlib import Path
BINARY = Path('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
BASE = 0x100000000
SEGMENTS = [
# (vaddr, vsize, fileoff, filesize, name)
(0x100000000, 0xe54000, 0x0, 0xe54000, '__TEXT'),
(0x100e54000, 0x88000, 0xe54000, 0x88000, '__DATA_CONST'),
(0x100edc000, 0x50c000, 0xedc000, 0x2bc000, '__DATA'),
(0x1013e8000, 0x70000, 0x1198000, 0x70000, '__ETC'),
(0x101458000, 0x138000, 0x1208000, 0x136410, '__LINKEDIT'),
]
_data = None
def data():
global _data
if _data is None:
_data = BINARY.read_bytes()
return _data
def va_to_off(va):
for vaddr, vsize, fileoff, filesize, name in SEGMENTS:
if vaddr <= va < vaddr + vsize:
delta = va - vaddr
if delta < filesize:
return fileoff + delta
return None
return None
def read_bytes(va, size):
off = va_to_off(va)
if off is None:
return None
return data()[off:off + size]
def read_u32(va):
b = read_bytes(va, 4)
if b is None or len(b) < 4:
return None
return struct.unpack('<I', b)[0]
def read_i32(va):
b = read_bytes(va, 4)
if b is None or len(b) < 4:
return None
return struct.unpack('<i', b)[0]
def read_u64(va):
b = read_bytes(va, 8)
if b is None or len(b) < 8:
return None
return struct.unpack('<Q', b)[0]
# _gen_audio_h / _gen_audio_h2 tail-merged body bounds
FUNC_START = 0x10090F20C
FUNC_END = 0x100A208FC
# whitebox blob range (docs/spec/znca_ios_whitebox_layout.md)
VA_LO = 0x100F70000
VA_HI = 0x101192000
# platform-init helper called from within _gen_audio_h
SET_PLATFORM_FN = 0x1007788EC
SET_PLATFORM_CALL_SITE = 0x1009593E8
# SHA-256 K[64] constant table copies found in this session (all 3 byte-identical)
SHA256_K_COPIES = [0x100cae7e0, 0x100cbbcc8, 0x100f77da0]
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Linear per-4-byte-slot capstone disassembly of _gen_audio_h/_gen_audio_h2
tail-merged body (0x10090f20c..0x100a208fc, ~1.07 MiB).
Same technique as analysis/cff/disasm_body.py: decode every 4-byte slot
independently so embedded CFF delta words never desync the byte alignment.
"""
import pickle
import struct
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import read_bytes, FUNC_START, FUNC_END
import capstone
CACHE = Path(__file__).parent / 'insns.pkl'
class Insn:
__slots__ = ('addr', 'mnemonic', 'op_str', 'size', 'bytes_')
def __init__(self, addr, mnemonic, op_str, size, bytes_):
self.addr = addr
self.mnemonic = mnemonic
self.op_str = op_str
self.size = size
self.bytes_ = bytes_
def __repr__(self):
return f"0x{self.addr:x}: {self.mnemonic} {self.op_str}"
def disasm_range(start, end, force=False):
if CACHE.exists() and not force:
with open(CACHE, 'rb') as f:
cached_start, cached_end, insns = pickle.load(f)
if cached_start == start and cached_end == end:
return insns
t0 = time.time()
blob = read_bytes(start, end - start)
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = False
insns = {}
addr = start
off = 0
n = len(blob)
while off < n:
chunk = blob[off:off + 4]
if len(chunk) < 4:
break
decoded = list(md.disasm(chunk, addr))
if decoded:
ins = decoded[0]
insns[addr] = Insn(addr, ins.mnemonic, ins.op_str, ins.size, chunk)
else:
word = struct.unpack('<I', chunk)[0]
insns[addr] = Insn(addr, '.word', f'0x{word:x}', 4, chunk)
addr += 4
off += 4
with open(CACHE, 'wb') as f:
pickle.dump((start, end, insns), f)
print(f"disasm_range: {len(insns)} slots in {time.time()-t0:.1f}s", file=sys.stderr)
return insns
if __name__ == '__main__':
insns = disasm_range(FUNC_START, FUNC_END, force=True)
print(f"decoded {len(insns)} 4-byte slots from 0x{FUNC_START:x} to 0x{FUNC_END:x}")
n_word = sum(1 for i in insns.values() if i.mnemonic == '.word')
print(f"undecodable (.word / embedded data) slots: {n_word}")
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Full-detail dump of every memory operation (through an x19-derived alias
register) whose resolved stack offset falls inside [0x3b000, 0x45380] -- the
zone the 8 SIMD `ldr q0,[Xbase,Xindex]` sites read from. Prints exact source
instruction addresses and both the alias-defining `add` instructions and the
consuming memory op, for direct inspection.
"""
import sys, re
sys.path.insert(0, '.')
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END
from find_dispatch import parse_ops
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
idx = {a: i for i, a in enumerate(addrs)}
pat_add12 = re.compile(r'^(x\d+|x19),\s*x19,\s*#(0x[0-9a-f]+|\d+),\s*lsl\s*#12$')
LO, HI = 0x3b000, 0x45380
n = len(addrs)
rows = []
for ii in range(n):
a = addrs[ii]
ins = insns[a]
if ins.mnemonic != 'add':
continue
m = pat_add12.match(ins.op_str)
if not m:
continue
dst = m.group(1)
hi = int(m.group(2), 0) << 12
total = hi
last_addr = a
add1_addr = a
add2_addr = None
if ii + 1 < n:
nxt = insns[addrs[ii + 1]]
if nxt.mnemonic == 'add':
ops2 = parse_ops(nxt.op_str)
if len(ops2) == 3 and ops2[0] == dst and ops2[1] == dst and ops2[2].startswith('#'):
try:
lo = int(ops2[2].lstrip('#'), 0)
total += lo
last_addr = addrs[ii + 1]
add2_addr = last_addr
except ValueError:
pass
if not (LO <= total <= HI):
continue
i2 = idx[last_addr]
if i2 + 1 >= n:
continue
use_addr = addrs[i2 + 1]
use = insns[use_addr]
m2 = re.search(r'\[([^\]]+)\]', use.op_str)
if not m2:
continue
parts = [p.strip() for p in m2.group(1).split(',')]
if parts[0] != dst:
continue
rows.append((total, add1_addr, add2_addr, use_addr, use.mnemonic, use.op_str))
rows.sort()
print(f"total ops: {len(rows)}\n")
for total, a1, a2, ua, m, o in rows:
print(f"stack_off=0x{total:06x} alias_def=0x{a1:x}{'/0x'+format(a2,'x') if a2 else ''} use=0x{ua:x} (func+0x{ua-FUNC_START:x}): {m} {o}")
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Task item #4: find the first and last instruction (by address / func offset)
where _gen_audio_h references the whitebox blob VA range [VA_LO, VA_HI) via
ADRP+ADD data-address formation, and dump the full ordered list for phase
inspection.
"""
import sys
sys.path.insert(0, '.')
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END, VA_LO, VA_HI
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
idx = {a: i for i, a in enumerate(addrs)}
pending = {}
hits = [] # (addr_of_add, target)
for i, a in enumerate(addrs):
ins = insns[a]
if ins.mnemonic == 'adrp':
parts = ins.op_str.split(',')
reg = parts[0].strip()
try:
pending[reg] = int(parts[1].strip().lstrip('#'), 0)
except ValueError:
pass
elif ins.mnemonic == 'add' and ',' in ins.op_str:
parts = [p.strip() for p in ins.op_str.split(',')]
if len(parts) == 3 and parts[1] in pending and parts[2].startswith('#'):
try:
imm = int(parts[2].lstrip('#'), 0)
tgt = pending[parts[1]] + imm
if VA_LO <= tgt < VA_HI:
hits.append((a, tgt))
except ValueError:
pass
print(f"total ADRP+ADD refs landing inside whitebox blob [0x{VA_LO:x},0x{VA_HI:x}): {len(hits)}")
if hits:
first_a, first_t = hits[0]
last_a, last_t = hits[-1]
print(f"FIRST touch: addr=0x{first_a:x} (func+0x{first_a-FUNC_START:x}) target=0x{first_t:x} (blob+0x{first_t-VA_LO:x})")
print(f"LAST touch: addr=0x{last_a:x} (func+0x{last_a-FUNC_START:x}) target=0x{last_t:x} (blob+0x{last_t-VA_LO:x})")
print(f"span across function body: func+0x{first_a-FUNC_START:x} .. func+0x{last_a-FUNC_START:x} (function total size 0x{FUNC_END-FUNC_START:x})")
print(f"\ndistinct targets touched: {len(set(t for _,t in hits))}")
print("\nfirst 15 and last 15 touches (address order):")
for a, t in hits[:15]:
print(f" func+0x{a-FUNC_START:07x} -> blob+0x{t-VA_LO:07x}")
print(" ...")
for a, t in hits[-15:]:
print(f" func+0x{a-FUNC_START:07x} -> blob+0x{t-VA_LO:07x}")
# rough phase segmentation: bucket touches into 20 equal-width slices of the function
# and report density, to see if blob access clusters in particular regions.
FSIZE = FUNC_END - FUNC_START
NBUCKET = 20
buckets = [0] * NBUCKET
for a, t in hits:
off = a - FUNC_START
b = min(NBUCKET - 1, off * NBUCKET // FSIZE)
buckets[b] += 1
print("\ndensity across function body (20 buckets):")
for i, c in enumerate(buckets):
lo = FSIZE * i // NBUCKET
hi = FSIZE * (i + 1) // NBUCKET
print(f" func+0x{lo:07x}-0x{hi:07x}: {c} touches" + (" " + "#" * min(c, 60) if c else ""))
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Find every occurrence of the CFF dispatch idiom in the initGenAudioH body
and resolve each site's `target_base`.
Two idiom variants observed:
Type A (indirect, with an embedded delta word):
adr xA, #TT
ldrsw xB, [xA]
add xA, xB, xA ; xA = TT + word_at(TT) == target_base
and xC2, xA, xSTATE
mov xD, #2
mul xC2, xC2, xD
eor xA, xA, xSTATE
add xA, xA, xC2 ; xA = target_base + state (a^b + 2*(a&b) == a+b)
br xA
Type B (direct, no indirection word):
adr xA, #TT ; target_base = TT directly
and xC2, xA, xSTATE
mov xD, #2
mul xC2, xC2, xD
eor xA, xA, xSTATE
add xA, xA, xC2
br xA
There is also a decoy/opaque "cancelling sub/add" idiom that is NOT a real
keyed dispatch (target is constant regardless of state); we detect and tag
those separately as `kind='opaque'`.
For each real dispatch site we then walk backward a short window to find the
`ldr xSTATE, [base, #off]` that loaded the state register, to identify which
private state slot (base register + stack offset, or global address) drives
that particular site.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import read_i32, FUNC_START, FUNC_END
from disasm_body import disasm_range
def parse_ops(op_str):
return [o.strip() for o in op_str.split(',')] if op_str else []
def find_dispatch_sites(insns, start, end):
addrs = sorted(a for a in insns if start <= a < end)
idx = {a: i for i, a in enumerate(addrs)}
sites = []
for i, a in enumerate(addrs):
ins = insns[a]
if ins.mnemonic != 'br':
continue
# walk backward: add, eor, mov(#2), mul, and, [ldrsw, add,] adr
if i < 8:
continue
seq = [insns[addrs[i - k]] for k in range(1, 9) if i - k >= 0]
# seq[0] = instr right before br (should be 'add')
m_add2 = seq[0]
m_eor = seq[1]
m_mul = seq[2]
m_movd = seq[3]
m_and = seq[4]
if m_add2.mnemonic != 'add':
continue
if m_eor.mnemonic != 'eor':
continue
if m_mul.mnemonic != 'mul':
continue
if m_movd.mnemonic not in ('mov', 'movz'):
continue
if m_and.mnemonic != 'and':
continue
and_ops = parse_ops(m_and.op_str)
eor_ops = parse_ops(m_eor.op_str)
if len(and_ops) != 3 or len(eor_ops) != 3:
continue
state_reg = and_ops[2]
if eor_ops[2] != state_reg:
continue
# movd should be `mov xD, #2`
movd_ops = parse_ops(m_movd.op_str)
if len(movd_ops) != 2 or movd_ops[1].lstrip('#') != '2':
continue
# now determine type A vs B by looking further back
m_next = seq[5] if len(seq) > 5 else None
m_next2 = seq[6] if len(seq) > 6 else None
kind = None
TT = None
word_val = None
adr_addr = None
if m_next is not None and m_next.mnemonic == 'adr':
kind = 'B'
ops = parse_ops(m_next.op_str)
if len(ops) == 2:
try:
TT = int(ops[1].lstrip('#'), 0)
adr_addr = m_next.addr
except ValueError:
continue
elif (m_next is not None and m_next.mnemonic == 'add' and
m_next2 is not None and m_next2.mnemonic == 'ldrsw'):
# need one more further back for adr (k=6 -> merge-add, k=7 -> ldrsw, k=8 -> adr)
k = 8
if i - k < 0:
continue
m_adr = insns[addrs[i - k]]
if m_adr.mnemonic != 'adr':
continue
kind = 'A'
ops = parse_ops(m_adr.op_str)
if len(ops) == 2:
try:
TT = int(ops[1].lstrip('#'), 0)
adr_addr = m_adr.addr
except ValueError:
continue
else:
continue
if TT is None:
continue
if kind == 'A':
word_val = read_i32(TT)
if word_val is None:
continue
target_base = (TT + word_val) & 0xFFFFFFFFFFFFFFFF
else:
target_base = TT
# search backward from adr_addr for the ldr of state_reg
state_load_addr = None
state_src_operand = None
j = idx[adr_addr] - 1
back_window = 0
while j >= 0 and back_window < 6:
back_a = addrs[j]
back_ins = insns[back_a]
back_ops = parse_ops(back_ins.op_str)
if back_ins.mnemonic in ('ldr', 'ldur') and back_ops and back_ops[0] == state_reg:
state_load_addr = back_a
if len(back_ops) >= 2:
state_src_operand = ','.join(back_ops[1:])
break
j -= 1
back_window += 1
sites.append({
'kind': kind,
'adr_addr': adr_addr,
'br_addr': a,
'TT': TT,
'word_val': word_val,
'target_base': target_base,
'state_reg': state_reg,
'state_load_addr': state_load_addr,
'state_src_operand': state_src_operand,
})
return sites
def normalize_slot(operand):
"""Turn '[x19,#0x278]' into ('x19', 0x278); '[x19]' into ('x19', 0)."""
if operand is None:
return None
op = operand.strip('[]')
parts = [p.strip() for p in op.split(',')]
base = parts[0]
off = 0
if len(parts) > 1 and parts[1].startswith('#'):
try:
off = int(parts[1].lstrip('#'), 0)
except ValueError:
off = None
return (base, off)
if __name__ == '__main__':
insns = disasm_range(FUNC_START, FUNC_END)
sites = find_dispatch_sites(insns, FUNC_START, FUNC_END)
from collections import Counter
kinds = Counter(s['kind'] for s in sites)
print(f"found {len(sites)} dispatch idiom instances: {dict(kinds)}")
unresolved_state = sum(1 for s in sites if s['state_load_addr'] is None)
print(f"sites where state load could not be found in backward window: {unresolved_state}")
slots = Counter(normalize_slot(s['state_src_operand']) for s in sites if s['state_src_operand'])
print(f"distinct state slots referenced by dispatch sites: {len(slots)}")
for slot, cnt in slots.most_common(20):
print(slot, cnt)
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, '.')
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END
from find_dispatch import parse_ops
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
idx = {a: i for i, a in enumerate(addrs)}
PTHREAD_CREATE_STUB = 0x100c347d0
call_addr = None
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'bl':
try:
t = int(ins.op_str.strip().lstrip('#'), 0)
except ValueError:
continue
if t == PTHREAD_CREATE_STUB:
call_addr = a
break
print(f"pthread_create call site: 0x{call_addr:x} (func+0x{call_addr-FUNC_START:x})" if call_addr else "NOT FOUND")
i = idx[call_addr]
print("\n=== context (60 before, 20 after) ===")
for k in range(max(0, i-60), i+20):
a = addrs[k]
marker = " <== bl pthread_create" if a == call_addr else ""
print(f"0x{a:x} (func+0x{a-FUNC_START:x}): {insns[a].mnemonic} {insns[a].op_str}{marker}")
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, '.')
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END
from collections import Counter
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
bl_targets = Counter()
bl_sites = []
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'bl':
try:
t = int(ins.op_str.strip().lstrip('#'), 0)
except ValueError:
continue
bl_targets[t] += 1
bl_sites.append((a, t))
print(f"total bl instructions: {len(bl_sites)}, distinct targets: {len(bl_targets)}")
funcs = {0x1006c8c8c: 'sha256_fn_A (references __TEXT K copy 0xcae7e0)',
0x1006ec35c: 'sha256_fn_B (references whitebox-blob K copy 0xf77da0)'}
for addr, name in funcs.items():
print(f"\n{name} @ 0x{addr:x}: called {bl_targets.get(addr,0)} times from _gen_audio_h")
sites = [s for s in bl_sites if s[1] == addr]
for s in sites[:30]:
print(f" call site 0x{s[0]:x} (func+0x{s[0]-FUNC_START:x})")
print("\ntop 30 most-called bl targets overall:")
for t, c in bl_targets.most_common(30):
print(f" 0x{t:x} calls={c}")
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
import sys, bisect
sys.path.insert(0, '.')
import lief
from common import read_bytes, VA_LO, VA_HI
import capstone
THREAD_FN = 0x1006f418c
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
fs = sorted(b.function_starts.functions)
BASE = 0x100000000
fs_va = [BASE + x for x in fs]
i = bisect.bisect_right(fs_va, THREAD_FN) - 1
func_start = fs_va[i]
func_end = fs_va[i+1] if i+1 < len(fs_va) else None
print(f"thread routine enclosing func_start=0x{func_start:x} func_end={hex(func_end)} size={hex(func_end-func_start)}")
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = False
blob = read_bytes(func_start, min(func_end - func_start, 0x4000))
print(f"\n=== first 80 instructions of thread routine (from actual entry point 0x{THREAD_FN:x} search) ===")
count = 0
pending = {}
data_refs = {}
bl_targets = {}
for insn in md.disasm(blob, func_start):
if insn.address < THREAD_FN:
continue
if count < 80:
print(f"0x{insn.address:x}: {insn.mnemonic} {insn.op_str}")
count += 1
if insn.mnemonic == 'adrp':
parts = insn.op_str.split(',')
pending[parts[0].strip()] = int(parts[1].strip().lstrip('#'), 0)
elif insn.mnemonic == 'add' and ',' in insn.op_str:
parts = [p.strip() for p in insn.op_str.split(',')]
if len(parts) == 3 and parts[1] in pending and parts[2].startswith('#'):
try:
imm = int(parts[2].lstrip('#'), 0)
tgt = pending[parts[1]] + imm
data_refs[tgt] = data_refs.get(tgt, 0) + 1
pending[parts[0]] = tgt
except ValueError:
pass
elif insn.mnemonic == 'bl':
try:
t = int(insn.op_str.strip().lstrip('#'), 0)
bl_targets[t] = bl_targets.get(t, 0) + 1
except ValueError:
pass
print(f"\ntotal instructions decoded in this thread-routine func range: {count}")
print(f"distinct ADRP+ADD data targets: {len(data_refs)}")
wb = {a: c for a, c in data_refs.items() if VA_LO <= a < VA_HI}
print(f"of which fall in whitebox blob 0x{VA_LO:x}-0x{VA_HI:x}: {len(wb)}")
for a, c in sorted(wb.items())[:20]:
print(f" 0x{a:x} (blob+0x{a-VA_LO:06x}) refs={c}")
print(f"\ndistinct bl targets: {len(bl_targets)}")
for t, c in sorted(bl_targets.items()):
print(f" 0x{t:x} calls={c}")
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Resolve CFF dispatch edges.
For each dispatch idiom site found by find_dispatch.py we know:
- target_base (a fixed VA, computable at rest, "this site's own home
address" essentially -- always target_base == br_addr + 4 for type B,
or (adr TT) + word_at(TT) for type A, which in every sample we saw also
reduces to the instruction immediately following the site's own embedded
word, i.e. target_base is basically "the address physically following
this dispatch instance").
- state_reg, and the exact (base_reg, imm_offset) memory slot that feeds
state_reg for this particular site.
The actual jump target executed at runtime is `target_base + state`, where
`state` was written into that private slot by a `str` instruction reached
earlier along the executed path. We scan the *entire* function body for every
`str`/`stur` that targets one of the known dispatch slots, backward-trace its
source register to a literal constant (mov/movz/movn, optionally
movz+movk pairs, or wzr/xzr), and -- if resolvable -- compute the concrete
edge `write_site -> target_base + constant`.
Writes whose source cannot be reduced to a compile-time constant in a short
backward window are flagged `dynamic` (this is exactly where genuine
runtime-dependent control flow, e.g. anti-debug results, would show up) and
are reported, not guessed.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END
from disasm_body import disasm_range
from find_dispatch import find_dispatch_sites, parse_ops, normalize_slot
MASK64 = (1 << 64) - 1
MASK32 = (1 << 32) - 1
def const_from_mov(ins):
"""Return (width, value) if `ins` is a mov/movz/movn/orr-with-xzr style
constant load, else None. Does not handle movk (caller chains)."""
ops = parse_ops(ins.op_str)
if not ops:
return None
dst = ops[0]
width = 64 if dst.startswith('x') else 32
if ins.mnemonic in ('mov', 'movz'):
if len(ops) == 2:
try:
val = int(ops[1].lstrip('#'), 0)
except ValueError:
# e.g. `mov x8, x9` register-to-register, not constant
if ops[1] in ('xzr', 'wzr'):
return dst, width, 0
return None
return dst, width, val & (MASK64 if width == 64 else MASK32)
elif len(ops) == 3 and 'lsl' in ops[2]:
try:
val = int(ops[1].lstrip('#'), 0)
shift = int(ops[2].split('#')[1].rstrip(')'), 0)
except (ValueError, IndexError):
return None
return dst, width, (val << shift) & (MASK64 if width == 64 else MASK32)
elif ins.mnemonic == 'movn':
if len(ops) == 2:
try:
val = int(ops[1].lstrip('#'), 0)
except ValueError:
return None
inv = (~val) & (MASK64 if width == 64 else MASK32)
return dst, width, inv
elif len(ops) == 3 and 'lsl' in ops[2]:
try:
val = int(ops[1].lstrip('#'), 0)
shift = int(ops[2].split('#')[1].rstrip(')'), 0)
except (ValueError, IndexError):
return None
inv = (~(val << shift)) & (MASK64 if width == 64 else MASK32)
return dst, width, inv
return None
def movk_apply(base_val, width, ins):
ops = parse_ops(ins.op_str)
if ins.mnemonic != 'movk' or len(ops) not in (2, 3):
return None
try:
val = int(ops[1].lstrip('#'), 0)
except ValueError:
return None
shift = 0
if len(ops) == 3 and 'lsl' in ops[2]:
try:
shift = int(ops[2].split('#')[1].rstrip(')'), 0)
except (ValueError, IndexError):
return None
mask = MASK64 if width == 64 else MASK32
clear_mask = ~(0xFFFF << shift) & mask
return (base_val & clear_mask) | ((val << shift) & mask)
def trace_const_backward(insns, addrs, idx, str_addr, src_reg, window=6):
"""Backward-trace src_reg from the instruction at str_addr to find a
constant definition. Returns (value, def_addrs) or None."""
i = idx[str_addr]
j = i - 1
steps = 0
# collect any movk chain first (movk instrs closest to the str, applied
# on top of an earlier movz/mov), then the base mov
movk_chain = []
while j >= 0 and steps < window:
a = addrs[j]
ins = insns[a]
ops = parse_ops(ins.op_str)
if ins.mnemonic == 'movk' and ops and ops[0] == src_reg:
movk_chain.append(ins)
j -= 1
steps += 1
continue
base = const_from_mov(ins)
if base is not None and base[0] == src_reg:
_, width, val = base
def_addrs = [ins.addr]
for mk in reversed(movk_chain):
newval = movk_apply(val, width, mk)
if newval is None:
return None
val = newval
def_addrs.append(mk.addr)
return val, def_addrs
j -= 1
steps += 1
return None
def scan_state_writers(insns, start, end, slot_index):
"""slot_index: dict (base_reg, off) -> list of site dicts.
Returns list of resolved/unresolved writer records."""
addrs = sorted(a for a in insns if start <= a < end)
idx = {a: i for i, a in enumerate(addrs)}
records = []
for a in addrs:
ins = insns[a]
if ins.mnemonic not in ('str', 'stur'):
continue
ops = parse_ops(ins.op_str)
if len(ops) < 2:
continue
src_reg = ops[0]
slot = normalize_slot(','.join(ops[1:]))
if slot is None or slot not in slot_index:
continue
# immediate wzr/xzr write
if src_reg in ('wzr', 'xzr'):
records.append({
'write_addr': a, 'slot': slot, 'value': 0,
'def_addrs': [a], 'resolved': True,
})
continue
res = trace_const_backward(insns, addrs, idx, a, src_reg)
if res is None:
records.append({
'write_addr': a, 'slot': slot, 'value': None,
'def_addrs': [], 'resolved': False,
})
else:
val, def_addrs = res
records.append({
'write_addr': a, 'slot': slot, 'value': val,
'def_addrs': def_addrs, 'resolved': True,
})
return records
if __name__ == '__main__':
insns = disasm_range(FUNC_START, FUNC_END)
sites = find_dispatch_sites(insns, FUNC_START, FUNC_END)
slot_index = {}
for s in sites:
slot = normalize_slot(s['state_src_operand'])
if slot is None:
continue
slot_index.setdefault(slot, []).append(s)
records = scan_state_writers(insns, FUNC_START, FUNC_END, slot_index)
resolved = [r for r in records if r['resolved']]
unresolved = [r for r in records if not r['resolved']]
print(f"total state-slot writers found: {len(records)}")
print(f"resolved (constant) writers: {len(resolved)}")
print(f"unresolved (dynamic-value) writers: {len(unresolved)}")
print("\nsample unresolved writers (first 20):")
for r in unresolved[:20]:
print(f" 0x{r['write_addr']:x} slot={r['slot']}")
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, '.')
import lief
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END, read_bytes
from collections import Counter
import struct
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
# Map __stubs entries to symbol names via the chained/lazy bindings.
# lief exposes b.symbols with a .value / .is_imported; for stub resolution,
# use dyld bind info: iterate b.dyld_chained_fixups or b.bindings.
stub_map = {}
try:
for bnd in b.bindings:
sym = bnd.symbol
addr = bnd.address
if sym is not None:
stub_map[addr] = sym.name
except Exception as e:
print("bindings iteration failed:", e)
print(f"resolved {len(stub_map)} bind records")
# find _CC_SHA256 related bind addresses
sha_binds = {a: n for a, n in stub_map.items() if 'SHA256' in n or 'SHA1' in n or 'MD5' in n}
print("crypto-related bind addresses (these are GOT/lazy-pointer slots, not stub code addrs):")
for a, n in sha_binds.items():
print(f" 0x{a:x} {n}")
# __stubs are short trampolines; each stub loads its GOT/lazy pointer and branches.
# Disassemble each stub in the __stubs section and record which GOT slot it loads from,
# to map stub_code_address -> symbol name.
import capstone
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
STUBS_LO = 0x100c2fbec
STUBS_SZ = 0x5850
STUBS_HI = STUBS_LO + STUBS_SZ
blob = read_bytes(STUBS_LO, STUBS_SZ)
stub_to_gotaddr = {}
addr = STUBS_LO
off = 0
insns_list = list(md.disasm(blob, STUBS_LO))
i = 0
while i < len(insns_list):
ins = insns_list[i]
if ins.mnemonic == 'adrp' and i + 1 < len(insns_list):
nxt = insns_list[i+1]
if nxt.mnemonic in ('ldr',) :
try:
page = ins.operands[1].imm
off_imm = nxt.operands[1].mem.disp
got = page + off_imm
stub_to_gotaddr[ins.address] = got
except Exception:
pass
i += 1
print(f"\nparsed {len(stub_to_gotaddr)} stub adrp+ldr pairs")
# now cross reference stub start addresses against gen_audio_h's bl targets
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
bl_targets = Counter()
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'bl':
try:
t = int(ins.op_str.strip().lstrip('#'), 0)
except ValueError:
continue
bl_targets[t] += 1
print("\n_gen_audio_h bl targets that land inside __stubs, resolved to import symbol if possible:")
for t, c in sorted(bl_targets.items()):
if STUBS_LO <= t < STUBS_HI:
got = stub_to_gotaddr.get(t)
if got is not None:
name = stub_map.get(got, '???')
print(f" 0x{t:x} calls={c} got=0x{got:x} -> {name}")
else:
print(f" 0x{t:x} calls={c} (no got resolved)")
print("\n--- reverse lookup: stub address for _CC_SHA256 specifically ---")
sha256_got = 0x100e57288
sha256_stub = None
for stub_addr, got in stub_to_gotaddr.items():
if got == sha256_got:
sha256_stub = stub_addr
break
print(f"_CC_SHA256 GOT slot = 0x{sha256_got:x}, stub trampoline address = {hex(sha256_stub) if sha256_stub else 'NOT FOUND'}")
if sha256_stub is not None:
print(f"is this stub called (bl) from _gen_audio_h? calls={bl_targets.get(sha256_stub, 0)}")
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Resolve the 3 bl targets found inside the pthread_create thread routine
(0x1006f418c-0x1006f444c) to import symbol names, reusing the stub->GOT->name
map building logic from resolve_stub_targets.py.
"""
import sys
sys.path.insert(0, '.')
import lief
from common import read_bytes
import capstone
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
stub_map = {}
for bnd in b.bindings:
sym = bnd.symbol
addr = bnd.address
if sym is not None:
stub_map[addr] = sym.name
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = False
STUBS_LO = 0x100c2fbec
STUBS_SZ = 0x5850
STUBS_HI = STUBS_LO + STUBS_SZ
blob = read_bytes(STUBS_LO, STUBS_SZ)
stub_to_gotaddr = {}
insns_list = list(md.disasm(blob, STUBS_LO))
i = 0
while i < len(insns_list):
ins = insns_list[i]
if ins.mnemonic == 'adrp' and i + 1 < len(insns_list):
nxt = insns_list[i + 1]
if nxt.mnemonic == 'ldr':
parts_a = ins.op_str.split(',')
page = int(parts_a[1].strip().lstrip('#'), 0)
# parse ldr operand2 "[xN, #off]"
import re
m = re.search(r'#(0x[0-9a-f]+|\d+)\]', nxt.op_str)
if m:
off_imm = int(m.group(1), 0)
got = page + off_imm
stub_to_gotaddr[ins.address] = got
i += 1
print(f"parsed {len(stub_to_gotaddr)} stub adrp+ldr pairs")
targets = [0x100c32fdc, 0x100c330a8, 0x100c33414]
for t in targets:
if STUBS_LO <= t < STUBS_HI:
got = stub_to_gotaddr.get(t)
if got is not None:
name = stub_map.get(got, '???')
print(f"0x{t:x} (in __stubs) -> got=0x{got:x} -> {name}")
else:
print(f"0x{t:x} (in __stubs) -> no GOT resolved at this exact adrp addr; scanning nearby")
# stub entries are typically 12 bytes (adrp+ldr+br); locate closest <= t
cands = sorted(a for a in stub_to_gotaddr if a <= t)
if cands:
near = cands[-1]
print(f" nearest stub adrp at 0x{near:x} (delta {t-near}) -> got=0x{stub_to_gotaddr[near]:x} -> {stub_map.get(stub_to_gotaddr[near], '???')}")
else:
print(f"0x{t:x} -> NOT inside __stubs range [0x{STUBS_LO:x},0x{STUBS_HI:x}); it is a direct/local function")
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Check whether _gen_audio_h itself (not a callee) directly references any
of the 3 known SHA-256 K[64] constant-table copies via ADRP+ADD, and also
scan more broadly for any as-yet-unknown 4th copy referenced from within
_gen_audio_h (by checking every ADRP+ADD target for the 256-byte K[64] byte
pattern at that address)."""
import sys
sys.path.insert(0, '.')
from disasm_body import disasm_range
from common import FUNC_START, FUNC_END, read_bytes
import capstone
K64 = [
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
]
import struct
PAT64 = b''.join(struct.pack('<I', x) for x in K64)
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
pending = {}
data_refs = {}
for a in addrs:
ins = insns[a]
if ins.mnemonic == 'adrp':
parts = ins.op_str.split(',')
if len(parts) == 2:
reg = parts[0].strip()
try:
imm = int(parts[1].strip().lstrip('#'), 0)
except ValueError:
continue
pending[reg] = imm
continue
if ins.mnemonic == 'add' and ',' in ins.op_str:
parts = [p.strip() for p in ins.op_str.split(',')]
if len(parts) == 3 and parts[1] in pending and parts[2].startswith('#'):
try:
imm = int(parts[2].lstrip('#'), 0)
except ValueError:
continue
tgt = pending[parts[1]] + imm
data_refs[tgt] = data_refs.get(tgt, 0) + 1
pending[parts[0]] = tgt
known = [0x100cae7e0, 0x100cbbcc8, 0x100f77da0]
print("direct refs within _gen_audio_h to known K[64] copies:")
for k in known:
print(f" 0x{k:x}: {data_refs.get(k, 0)} refs")
print(f"\ntotal distinct ADRP+ADD data targets in _gen_audio_h: {len(data_refs)}")
# check ALL distinct targets for the K64 pattern (may reveal a private 4th copy)
hits = []
for t in data_refs:
b = read_bytes(t, 256)
if b == PAT64:
hits.append(t)
print(f"\ntargets that match full K[64] byte pattern: {hits}")
# also check for PARTIAL match (first 8 words) in case of a truncated/reordered copy
PAT8 = PAT64[:32]
partial = []
for t in data_refs:
b = read_bytes(t, 32)
if b == PAT8:
partial.append(t)
print(f"targets matching first-8-words K pattern: {partial}")
@@ -0,0 +1,120 @@
total ops: 118
stack_off=0x03bbe0 alias_def=0x10095c230/0x10095c234 use=0x10095c238 (func+0x4d02c): str q3, [x8, #0x90]
stack_off=0x03bbe0 alias_def=0x1009847e8/0x1009847ec use=0x1009847f0 (func+0x755e4): ldr x8, [x10, #0x98]
stack_off=0x03bbe0 alias_def=0x10098526c/0x100985270 use=0x100985274 (func+0x76068): ldr x8, [x8, #0x40]
stack_off=0x03bbe0 alias_def=0x100987dd0/0x100987dd4 use=0x100987dd8 (func+0x78bcc): ldr x8, [x10, #0x60]
stack_off=0x03bbe0 alias_def=0x100988e78/0x100988e7c use=0x100988e80 (func+0x79c74): ldr x8, [x10, #0x48]
stack_off=0x03bbe0 alias_def=0x10098d444/0x10098d448 use=0x10098d44c (func+0x7e240): ldr x8, [x10, #0xb0]
stack_off=0x03bbe0 alias_def=0x100992328/0x10099232c use=0x100992330 (func+0x83124): str x8, [x9, #0x1e0]
stack_off=0x03bbe0 alias_def=0x1009976ec/0x1009976f0 use=0x1009976f4 (func+0x884e8): ldr x8, [x10, #0x190]
stack_off=0x03bbe0 alias_def=0x10099accc/0x10099acd0 use=0x10099acd4 (func+0x8bac8): ldr x8, [x10, #0x158]
stack_off=0x03bbe0 alias_def=0x10099bd7c/0x10099bd80 use=0x10099bd84 (func+0x8cb78): ldr x8, [x10, #0x140]
stack_off=0x03bbe0 alias_def=0x1009a0330/0x1009a0334 use=0x1009a0338 (func+0x9112c): ldr x8, [x10, #0x1a8]
stack_off=0x03bbe0 alias_def=0x1009a95a0/0x1009a95a4 use=0x1009a95a8 (func+0x9a39c): str x8, [x9, #0x58]
stack_off=0x03bbe0 alias_def=0x1009ab0cc/0x1009ab0d0 use=0x1009ab0d4 (func+0x9bec8): ldr x8, [x8, #0x40]
stack_off=0x03bbe0 alias_def=0x1009aecf0/0x1009aecf4 use=0x1009aecf8 (func+0x9faec): ldr x8, [x10, #0x48]
stack_off=0x03bbe0 alias_def=0x1009ce5dc/0x1009ce5e0 use=0x1009ce5e4 (func+0xbf3d8): ldr x8, [x8, #0x40]
stack_off=0x03bbe0 alias_def=0x1009d21e8/0x1009d21ec use=0x1009d21f0 (func+0xc2fe4): ldr x8, [x10, #0x48]
stack_off=0x03bbe0 alias_def=0x1009e9ae4/0x1009e9ae8 use=0x1009e9aec (func+0xda8e0): str x8, [x9, #0xe8]
stack_off=0x03bbe0 alias_def=0x1009f3544/0x1009f3548 use=0x1009f354c (func+0xe4340): ldr x8, [x11, #0x1d8]
stack_off=0x03bbe0 alias_def=0x1009f83a8/0x1009f83ac use=0x1009f83b0 (func+0xe91a4): ldr x8, [x10, #0x190]
stack_off=0x03bbe0 alias_def=0x1009f8e2c/0x1009f8e30 use=0x1009f8e34 (func+0xe9c28): ldr x8, [x8, #0x138]
stack_off=0x03bbe0 alias_def=0x1009fb990/0x1009fb994 use=0x1009fb998 (func+0xec78c): ldr x8, [x10, #0x158]
stack_off=0x03bbe0 alias_def=0x1009fca38/0x1009fca3c use=0x1009fca40 (func+0xed834): ldr x8, [x10, #0x140]
stack_off=0x03bbe0 alias_def=0x100a01004/0x100a01008 use=0x100a0100c (func+0xf1e00): ldr x8, [x10, #0x1a8]
stack_off=0x03bbe0 alias_def=0x100a06598/0x100a0659c use=0x100a065a0 (func+0xf7394): ldr x8, [x8, #0x148]
stack_off=0x03bbe0 alias_def=0x100a09dc4/0x100a09dc8 use=0x100a09dcc (func+0xfabc0): ldr x8, [x10, #0xe0]
stack_off=0x03bbe0 alias_def=0x100a0f824/0x100a0f828 use=0x100a0f82c (func+0x100620): ldr x8, [x8, #0x138]
stack_off=0x03bbe0 alias_def=0x100a13460/0x100a13464 use=0x100a13468 (func+0x10425c): ldr x8, [x9, #0x140]
stack_off=0x03bbe0 alias_def=0x100a1c8cc/0x100a1c8d0 use=0x100a1c8d4 (func+0x10d6c8): str x24, [x8, #0x1e0]
stack_off=0x03bcd8 alias_def=0x10095d82c/0x10095d830 use=0x10095d834 (func+0x4e628): ldr x8, [x10, #0x138]
stack_off=0x03bcd8 alias_def=0x10095f150/0x10095f154 use=0x10095f158 (func+0x4ff4c): ldr x8, [x8, #0x138]
stack_off=0x03bcd8 alias_def=0x100962d5c/0x100962d60 use=0x100962d64 (func+0x53b58): ldr x8, [x10, #0x140]
stack_off=0x03bcd8 alias_def=0x100973f6c/0x100973f70 use=0x100973f74 (func+0x64d68): ldr x8, [x11, #0x1d8]
stack_off=0x03bcd8 alias_def=0x100974df0/0x100974df4 use=0x100974df8 (func+0x65bec): ldr x8, [x12, #0x138]
stack_off=0x03bcd8 alias_def=0x100975db0/0x100975db4 use=0x100975db8 (func+0x66bac): ldr x8, [x10, #0x190]
stack_off=0x03bcd8 alias_def=0x1009793a8/0x1009793ac use=0x1009793b0 (func+0x6a1a4): ldr x8, [x10, #0x158]
stack_off=0x03bcd8 alias_def=0x10097a468/0x10097a46c use=0x10097a470 (func+0x6b264): ldr x8, [x10, #0x140]
stack_off=0x03bcd8 alias_def=0x10097ea24/0x10097ea28 use=0x10097ea2c (func+0x6f820): ldr x8, [x10, #0x1a8]
stack_off=0x03bcd8 alias_def=0x1009a5940/0x1009a5944 use=0x1009a5948 (func+0x9673c): str x8, [x9, #0x1e0]
stack_off=0x03bcd8 alias_def=0x1009b8008/0x1009b800c use=0x1009b8010 (func+0xa8e04): ldr x8, [x8, #0x1e0]
stack_off=0x03bcd8 alias_def=0x1009be508/0x1009be50c use=0x1009be510 (func+0xaf304): ldr x8, [x8, #0x138]
stack_off=0x03bcd8 alias_def=0x1009c210c/0x1009c2110 use=0x1009c2114 (func+0xb2f08): ldr x8, [x10, #0x140]
stack_off=0x03bcd8 alias_def=0x1009deb74/0x1009deb78 use=0x1009deb7c (func+0xcf970): ldr x8, [x8, #0x148]
stack_off=0x03bcd8 alias_def=0x1009e6ec8/0x1009e6ecc use=0x1009e6ed0 (func+0xd7cc4): str x8, [x9, #0x1e0]
stack_off=0x03bcd8 alias_def=0x100a1c91c/0x100a1c920 use=0x100a1c924 (func+0x10d718): str q3, [x8, #0x90]
stack_off=0x03bdd0 alias_def=0x100946414/0x100946418 use=0x10094641c (func+0x37210): str x12, [x8, #0x5500]
stack_off=0x03bdd0 alias_def=0x1009570dc/0x1009570e0 use=0x1009570e4 (func+0x47ed8): strb w8, [x9, #0x1bc]
stack_off=0x03bdd0 alias_def=0x1009a7500/0x1009a7504 use=0x1009a7508 (func+0x982fc): str s0, [x8, #0x23c]
stack_off=0x03bdd0 alias_def=0x1009e6f18/0x1009e6f1c use=0x1009e6f20 (func+0xd7d14): str q3, [x9, #0x90]
stack_off=0x03bdd0 alias_def=0x1009ea504/0x1009ea508 use=0x1009ea50c (func+0xdb300): strb w8, [x9, #0x1fc]
stack_off=0x03bdd0 alias_def=0x100a0da7c/0x100a0da80 use=0x100a0da84 (func+0xfe878): str x9, [x10, #0x58]
stack_off=0x03bf8c alias_def=0x1009ec2d8/0x1009ec2dc use=0x1009ec2e0 (func+0xdd0d4): ldr q0, [x9, x8]
stack_off=0x03bfcc alias_def=0x1009ef59c/0x1009ef5a0 use=0x1009ef5a4 (func+0xe0398): ldr q0, [x10, x9]
stack_off=0x03c00c alias_def=0x1009ece70/0x1009ece74 use=0x1009ece78 (func+0xddc6c): ldr q0, [x9, x8]
stack_off=0x03ee50 alias_def=0x10093a678/0x10093a67c use=0x10093a680 (func+0x2b474): ldrsb w8, [x8, #0xfff]
stack_off=0x0412e8 alias_def=0x10093e3c8/0x10093e3cc use=0x10093e3d0 (func+0x2f1c4): strb wzr, [x8, #0x7f]
stack_off=0x0412e8 alias_def=0x10093ed6c/0x10093ed70 use=0x10093ed74 (func+0x2fb68): strb wzr, [x8, #0xff]
stack_off=0x0412e8 alias_def=0x10093f710/0x10093f714 use=0x10093f718 (func+0x3050c): strb wzr, [x8, #0x17f]
stack_off=0x0412e8 alias_def=0x100940034/0x100940038 use=0x10094003c (func+0x30e30): strb wzr, [x8, #0x1ff]
stack_off=0x0412e8 alias_def=0x100940958/0x10094095c use=0x100940960 (func+0x31754): strb wzr, [x8, #0x27f]
stack_off=0x0412e8 alias_def=0x10094127c/0x100941280 use=0x100941284 (func+0x32078): strb wzr, [x8, #0x2ff]
stack_off=0x0412e8 alias_def=0x100941ba0/0x100941ba4 use=0x100941ba8 (func+0x3299c): strb wzr, [x8, #0x37f]
stack_off=0x0412e8 alias_def=0x10094247c/0x100942480 use=0x100942484 (func+0x33278): strb wzr, [x8, #0x3ff]
stack_off=0x0412e8 alias_def=0x100942d18/0x100942d1c use=0x100942d20 (func+0x33b14): strb wzr, [x8, #0x47f]
stack_off=0x0412e8 alias_def=0x100943d68/0x100943d6c use=0x100943d70 (func+0x34b64): strb wzr, [x8, #0x4ff]
stack_off=0x0412e8 alias_def=0x1009445ec/0x1009445f0 use=0x1009445f4 (func+0x353e8): strb wzr, [x8, #0x57f]
stack_off=0x0412e8 alias_def=0x100946d70/0x100946d74 use=0x100946d78 (func+0x37b6c): strb wzr, [x8, #0x5ff]
stack_off=0x0412e8 alias_def=0x100947684/0x100947688 use=0x10094768c (func+0x38480): strb wzr, [x8, #0x67f]
stack_off=0x0412e8 alias_def=0x100947f98/0x100947f9c use=0x100947fa0 (func+0x38d94): strb wzr, [x8, #0x6ff]
stack_off=0x0412e8 alias_def=0x100948874/0x100948878 use=0x10094887c (func+0x39670): strb wzr, [x8, #0x77f]
stack_off=0x0412e8 alias_def=0x100949140/0x100949144 use=0x100949148 (func+0x39f3c): strb wzr, [x8, #0x7ff]
stack_off=0x0412e8 alias_def=0x1009499cc/0x1009499d0 use=0x1009499d4 (func+0x3a7c8): strb wzr, [x8, #0x87f]
stack_off=0x0412e8 alias_def=0x10094a248/0x10094a24c use=0x10094a250 (func+0x3b044): strb wzr, [x8, #0x8ff]
stack_off=0x0412e8 alias_def=0x10094aac4/0x10094aac8 use=0x10094aacc (func+0x3b8c0): strb wzr, [x8, #0x97f]
stack_off=0x0412e8 alias_def=0x10094b340/0x10094b344 use=0x10094b348 (func+0x3c13c): strb wzr, [x8, #0x9ff]
stack_off=0x0412e8 alias_def=0x10094c2f0/0x10094c2f4 use=0x10094c2f8 (func+0x3d0ec): strb wzr, [x8, #0xa7f]
stack_off=0x0412e8 alias_def=0x10094cb6c/0x10094cb70 use=0x10094cb74 (func+0x3d968): strb wzr, [x8, #0xaff]
stack_off=0x0412e8 alias_def=0x10094f548/0x10094f54c use=0x10094f550 (func+0x40344): str wzr, [x9, #0xb20]
stack_off=0x0412e8 alias_def=0x10094f5e8/0x10094f5ec use=0x10094f5f0 (func+0x403e4): str w8, [x9, #0xd94]
stack_off=0x0412e8 alias_def=0x100950084/0x100950088 use=0x10095008c (func+0x40e80): ldr w8, [x8, #0xb20]
stack_off=0x0412e8 alias_def=0x100954d10/0x100954d14 use=0x100954d18 (func+0x45b0c): ldr w8, [x8, #0x1768]
stack_off=0x0412e8 alias_def=0x100955324/0x100955328 use=0x10095532c (func+0x46120): str wzr, [x9, #0x1768]
stack_off=0x0412e8 alias_def=0x100955450/0x100955454 use=0x100955458 (func+0x4624c): ldr w8, [x8, #0x19dc]
stack_off=0x0412e8 alias_def=0x100955630/0x100955634 use=0x100955638 (func+0x4642c): ldr w8, [x8, #0x1258]
stack_off=0x0412e8 alias_def=0x100955af8/0x100955afc use=0x100955b00 (func+0x468f4): ldr w8, [x8, #0x14e0]
stack_off=0x0412e8 alias_def=0x10095c218/0x10095c21c use=0x10095c220 (func+0x4d014): ldr q0, [x9, #0x1d00]
stack_off=0x043068 alias_def=0x10095c338/0x10095c33c use=0x10095c340 (func+0x4d134): strb w8, [x9]
stack_off=0x043068 alias_def=0x10095c6c0/0x10095c6c4 use=0x10095c6c8 (func+0x4d4bc): ldr q0, [x9, x8]
stack_off=0x043068 alias_def=0x100962db4/0x100962db8 use=0x100962dbc (func+0x53bb0): ldr x8, [x8, #0x40]
stack_off=0x043068 alias_def=0x100967058/0x10096705c use=0x100967060 (func+0x57e54): str x1, [x2, #0x40]
stack_off=0x043068 alias_def=0x10097a4c8/0x10097a4cc use=0x10097a4d0 (func+0x6b2c4): ldr x8, [x8, #0xc0]
stack_off=0x043068 alias_def=0x10097e77c/0x10097e780 use=0x10097e784 (func+0x6f578): str x1, [x2, #0xc0]
stack_off=0x043068 alias_def=0x100988ed0/0x100988ed4 use=0x100988ed8 (func+0x79ccc): ldr x8, [x8, #0x140]
stack_off=0x043068 alias_def=0x10098d19c/0x10098d1a0 use=0x10098d1a4 (func+0x7df98): str x1, [x2, #0x140]
stack_off=0x043068 alias_def=0x100994e94/0x100994e98 use=0x100994e9c (func+0x85c90): ldr w8, [x8, #0xa10]
stack_off=0x043068 alias_def=0x100994f08/0x100994f0c use=0x100994f10 (func+0x85d04): ldr w11, [x8, #0xc84]
stack_off=0x043068 alias_def=0x10099515c/0x100995160 use=0x100995164 (func+0x85f58): ldr w8, [x8, #0x500]
stack_off=0x043068 alias_def=0x1009953ac/0x1009953b0 use=0x1009953b4 (func+0x861a8): ldr w8, [x8, #0x788]
stack_off=0x043068 alias_def=0x1009958d0/0x1009958d4 use=0x1009958d8 (func+0x866cc): str wzr, [x11, #0xa10]
stack_off=0x043068 alias_def=0x10099bdd4/0x10099bdd8 use=0x10099bddc (func+0x8cbd0): ldr x8, [x8, #0xc88]
stack_off=0x043068 alias_def=0x1009a0088/0x1009a008c use=0x1009a0090 (func+0x90e84): str x1, [x2, #0xc88]
stack_off=0x043068 alias_def=0x1009aed48/0x1009aed4c use=0x1009aed50 (func+0x9fb44): ldr x8, [x8, #0x1028]
stack_off=0x043068 alias_def=0x1009b2ffc/0x1009b3000 use=0x1009b3004 (func+0xa3df8): str x1, [x2, #0x1028]
stack_off=0x043068 alias_def=0x1009c2164/0x1009c2168 use=0x1009c216c (func+0xb2f60): ldr x8, [x8, #0x10a8]
stack_off=0x043068 alias_def=0x1009c6430/0x1009c6434 use=0x1009c6438 (func+0xb722c): str x1, [x2, #0x10a8]
stack_off=0x043068 alias_def=0x1009d2240/0x1009d2244 use=0x1009d2248 (func+0xc303c): ldr x8, [x8, #0x1128]
stack_off=0x043068 alias_def=0x1009d64e4/0x1009d64e8 use=0x1009d64ec (func+0xc72e0): str x1, [x2, #0x1128]
stack_off=0x044530 alias_def=0x1009e6534/0x1009e6538 use=0x1009e653c (func+0xd7330): ldr q1, [x9]
stack_off=0x044530 alias_def=0x1009e6f00/0x1009e6f04 use=0x1009e6f08 (func+0xd7cfc): ldur q0, [x8, #0x18]
stack_off=0x044530 alias_def=0x1009e7020/0x1009e7024 use=0x1009e7028 (func+0xd7e1c): strb w8, [x9, #0x98]
stack_off=0x044530 alias_def=0x1009e8018/0x1009e801c use=0x1009e8020 (func+0xd8e14): str w8, [x9, #0xc68]
stack_off=0x044530 alias_def=0x1009e80d4/0x1009e80d8 use=0x1009e80dc (func+0xd8ed0): str w21, [x8, #0xc74]
stack_off=0x044530 alias_def=0x1009fca90/0x1009fca94 use=0x1009fca98 (func+0xed88c): ldr x8, [x8, #0xc78]
stack_off=0x044530 alias_def=0x100a00d5c/0x100a00d60 use=0x100a00d64 (func+0xf1b58): str x1, [x2, #0xc78]
stack_off=0x044530 alias_def=0x100a134b8/0x100a134bc use=0x100a134c0 (func+0x1042b4): ldr x8, [x8, #0xcf8]
stack_off=0x044530 alias_def=0x100a1779c/0x100a177a0 use=0x100a177a4 (func+0x108598): str x1, [x2, #0xcf8]
stack_off=0x0445c8 alias_def=0x1009e73a8/0x1009e73ac use=0x1009e73b0 (func+0xd81a4): ldr q0, [x9, x8]
stack_off=0x0452a8 alias_def=0x100a1c904/0x100a1c908 use=0x100a1c90c (func+0x10d700): ldr q0, [x9]
stack_off=0x0452a8 alias_def=0x100a1ca24/0x100a1ca28 use=0x100a1ca2c (func+0x10d820): strb w8, [x9, #0x80]
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Trace the base/index register origin of the 8 SIMD `ldr qN, [Xbase, Xindex]`
register+register loads in _gen_audio_h, walking backward across CFF-resolved
pseudo-basic-block boundaries using cfg.pkl's dispatch_edges/branches to find
true predecessors (since the immediately-preceding bytes in file order are
NOT necessarily the actual predecessor once CFF has scrambled block layout).
"""
import pickle
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from common import FUNC_START, FUNC_END, VA_LO, VA_HI
from disasm_body import disasm_range
from find_dispatch import parse_ops
insns = disasm_range(FUNC_START, FUNC_END)
addrs = sorted(insns)
idx = {a: i for i, a in enumerate(addrs)}
with open(Path(__file__).parent / 'cfg.pkl', 'rb') as f:
g = pickle.load(f)
leaders = g['leaders']
blocks = g['blocks']
leader_arr = leaders # sorted list
import bisect
def block_containing(addr):
i = bisect.bisect_right(leader_arr, addr) - 1
if i < 0:
return None
return leader_arr[i]
# build predecessor map: for every block, who points to it?
preds = {}
for leader, b in blocks.items():
for e in b['out']:
kind = e[0]
tgt = e[1]
if kind in ('cff', 'b', 'fallthrough') and tgt is not None:
preds.setdefault(tgt, []).append((leader, kind))
elif kind.startswith('b.') or kind in ('cbz', 'cbnz', 'tbz', 'tbnz'):
if tgt is not None:
preds.setdefault(tgt, []).append((leader, kind))
TARGETS = [
(0x10095c6c8, 'ldr q0, [x9, x8]'),
(0x1009e73b0, 'ldr q0, [x9, x8]'),
(0x1009ec2e0, 'ldr q0, [x9, x8]'),
(0x1009ece78, 'ldr q0, [x9, x8]'),
(0x1009ef5a4, 'ldr q0, [x10, x9]'),
(0x100a0d8d4, 'ldr q0, [x9, x8]'),
(0x100a1cdac, 'ldr q0, [x9, x8]'),
(0x100a1dff8, 'ldr q0, [x8, x11]'),
]
def instrs_in_block(leader):
b = blocks[leader]
end = b['end']
i = idx[leader]
j = idx[end]
return addrs[i:j+1]
def dump_block(leader, upto=None, tail=40):
ins_list = instrs_in_block(leader)
if upto is not None:
ins_list = [a for a in ins_list if a <= upto]
ins_list = ins_list[-tail:]
lines = []
for a in ins_list:
ins = insns[a]
lines.append(f" 0x{a:x}: {ins.mnemonic} {ins.op_str}")
return lines
def find_def_in_block(leader, upto_addr, reg):
"""Scan block backward from upto_addr (exclusive) for last write to reg."""
ins_list = [a for a in instrs_in_block(leader) if a < upto_addr]
for a in reversed(ins_list):
ins = insns[a]
ops = parse_ops(ins.op_str)
if not ops:
continue
dst = ops[0]
# normalize wN vs xN name match loosely (same reg number, ignore width)
if dst == reg:
return a, ins
# also match if dst is w-form of an x-form reg name request or vice versa
if dst[0] in 'wx' and reg[0] in 'wx' and dst[1:] == reg[1:]:
return a, ins
return None
def trace_register(leader, use_addr, reg, depth=0, visited=None, max_depth=6):
"""Backward walk: find where `reg` is defined, following predecessor
blocks (via CFF-resolved edges) if not defined within the current block."""
if visited is None:
visited = set()
result = []
definition = find_def_in_block(leader, use_addr, reg)
if definition is not None:
a, ins = definition
result.append((leader, a, ins.mnemonic, ins.op_str))
# if it's a mov/adrp/add-immediate we can stop; if it's `mov reg, otherreg`
# or add reg, otherreg, ... follow the source register too (one more hop)
ops = parse_ops(ins.op_str)
if ins.mnemonic in ('mov',) and len(ops) == 2 and ops[1] and ops[1][0] in 'wx' and not ops[1].lstrip('-').isdigit():
# register-to-register mov; keep tracing that source reg from same point
src = ops[1]
if depth < max_depth:
sub = trace_register(leader, a, src, depth + 1, visited, max_depth)
result.extend(sub)
return result
# not defined in this block -> walk predecessors
key = (leader, reg)
if key in visited or depth >= max_depth:
return [(leader, None, 'UNRESOLVED(depth/visited limit)', reg)]
visited.add(key)
pred_list = preds.get(leader, [])
if not pred_list:
return [(leader, None, 'UNRESOLVED(no predecessors found)', reg)]
for pred_leader, kind in pred_list[:3]: # cap fan-in explored
pend = blocks[pred_leader]['end']
sub = trace_register(pred_leader, pend + 4, reg, depth + 1, visited, max_depth)
result.append((leader, None, f'-> pred block 0x{pred_leader:x} via {kind}', reg))
result.extend(sub)
return result
for site_addr, mnem_op in TARGETS:
print("=" * 100)
print(f"SITE 0x{site_addr:x}: {mnem_op}")
leader = block_containing(site_addr)
print(f" containing pseudo-block leader: 0x{leader:x} (offset from func start: 0x{site_addr - FUNC_START:x})")
print(f" preceding instructions in block:")
for l in dump_block(leader, upto=site_addr, tail=25):
print(l)
ops = parse_ops(insns[site_addr].op_str)
# ops[-1] like "[x9, x8]" -> split
membrace = ops[-1] if len(ops) == 2 else ','.join(ops[1:])
inner = membrace.strip('[]')
parts = [p.strip() for p in inner.split(',')]
base_reg, idx_reg = parts[0], parts[1]
print(f"\n base register = {base_reg}, index register = {idx_reg}")
print(f"\n --- tracing base register {base_reg} ---")
trace = trace_register(leader, site_addr, base_reg)
for t in trace:
l, a, m, o = t
if a is not None:
print(f" [block 0x{l:x}] 0x{a:x}: {m} {o}")
else:
print(f" [block 0x{l:x}] {m}")
print(f"\n --- tracing index register {idx_reg} ---")
trace2 = trace_register(leader, site_addr, idx_reg)
for t in trace2:
l, a, m, o = t
if a is not None:
print(f" [block 0x{l:x}] 0x{a:x}: {m} {o}")
else:
print(f" [block 0x{l:x}] {m}")
print()
@@ -0,0 +1,142 @@
====================================================================================================
SITE 0x10095c6c8: ldr q0, [x9, x8]
containing pseudo-block leader: 0x10095c6bc (offset from func start: 0x4d4bc)
preceding instructions in block:
0x10095c6bc: lsl x8, x11, #3
0x10095c6c0: add x9, x19, #0x43, lsl #12
0x10095c6c4: add x9, x9, #0x68
0x10095c6c8: ldr q0, [x9, x8]
base register = x9, index register = x8
--- tracing base register x9 ---
[block 0x10095c6bc] 0x10095c6c4: add x9, x9, #0x68
--- tracing index register x8 ---
[block 0x10095c6bc] 0x10095c6bc: lsl x8, x11, #3
====================================================================================================
SITE 0x1009e73b0: ldr q0, [x9, x8]
containing pseudo-block leader: 0x1009e73a4 (offset from func start: 0xd81a4)
preceding instructions in block:
0x1009e73a4: lsl x8, x11, #3
0x1009e73a8: add x9, x19, #0x44, lsl #12
0x1009e73ac: add x9, x9, #0x5c8
0x1009e73b0: ldr q0, [x9, x8]
base register = x9, index register = x8
--- tracing base register x9 ---
[block 0x1009e73a4] 0x1009e73ac: add x9, x9, #0x5c8
--- tracing index register x8 ---
[block 0x1009e73a4] 0x1009e73a4: lsl x8, x11, #3
====================================================================================================
SITE 0x1009ec2e0: ldr q0, [x9, x8]
containing pseudo-block leader: 0x1009ec2d4 (offset from func start: 0xdd0d4)
preceding instructions in block:
0x1009ec2d4: lsl x8, x11, #3
0x1009ec2d8: add x9, x19, #0x3b, lsl #12
0x1009ec2dc: add x9, x9, #0xf8c
0x1009ec2e0: ldr q0, [x9, x8]
base register = x9, index register = x8
--- tracing base register x9 ---
[block 0x1009ec2d4] 0x1009ec2dc: add x9, x9, #0xf8c
--- tracing index register x8 ---
[block 0x1009ec2d4] 0x1009ec2d4: lsl x8, x11, #3
====================================================================================================
SITE 0x1009ece78: ldr q0, [x9, x8]
containing pseudo-block leader: 0x1009ece6c (offset from func start: 0xddc6c)
preceding instructions in block:
0x1009ece6c: lsl x8, x11, #3
0x1009ece70: add x9, x19, #0x3c, lsl #12
0x1009ece74: add x9, x9, #0xc
0x1009ece78: ldr q0, [x9, x8]
base register = x9, index register = x8
--- tracing base register x9 ---
[block 0x1009ece6c] 0x1009ece74: add x9, x9, #0xc
--- tracing index register x8 ---
[block 0x1009ece6c] 0x1009ece6c: lsl x8, x11, #3
====================================================================================================
SITE 0x1009ef5a4: ldr q0, [x10, x9]
containing pseudo-block leader: 0x1009ef598 (offset from func start: 0xe0398)
preceding instructions in block:
0x1009ef598: lsl x9, x8, #3
0x1009ef59c: add x10, x19, #0x3b, lsl #12
0x1009ef5a0: add x10, x10, #0xfcc
0x1009ef5a4: ldr q0, [x10, x9]
base register = x10, index register = x9
--- tracing base register x10 ---
[block 0x1009ef598] 0x1009ef5a0: add x10, x10, #0xfcc
--- tracing index register x9 ---
[block 0x1009ef598] 0x1009ef598: lsl x9, x8, #3
====================================================================================================
SITE 0x100a0d8d4: ldr q0, [x9, x8]
containing pseudo-block leader: 0x100a0d8d0 (offset from func start: 0xfe6c8)
preceding instructions in block:
0x100a0d8d0: ldr x9, [x19, #0x49f8]
0x100a0d8d4: ldr q0, [x9, x8]
base register = x9, index register = x8
--- tracing base register x9 ---
[block 0x100a0d8d0] 0x100a0d8d0: ldr x9, [x19, #0x49f8]
--- tracing index register x8 ---
[block 0x100a0d8d0] -> pred block 0x100957180 via b
[block 0x100957180] 0x10095718c: mov x8, #0
[block 0x100a0d8d0] -> pred block 0x1009961e4 via b
[block 0x1009961e4] -> pred block 0x1009961b8 via tbz
[block 0x1009961b8] 0x1009961d8: ldr x8, [x19, #0x4a28]
====================================================================================================
SITE 0x100a1cdac: ldr q0, [x9, x8]
containing pseudo-block leader: 0x100a1cda4 (offset from func start: 0x10dba0)
preceding instructions in block:
0x100a1cda4: lsl x8, x11, #3
0x100a1cda8: sub x9, x29, #0xa8
0x100a1cdac: ldr q0, [x9, x8]
base register = x9, index register = x8
--- tracing base register x9 ---
[block 0x100a1cda4] 0x100a1cda8: sub x9, x29, #0xa8
--- tracing index register x8 ---
[block 0x100a1cda4] 0x100a1cda4: lsl x8, x11, #3
====================================================================================================
SITE 0x100a1dff8: ldr q0, [x8, x11]
containing pseudo-block leader: 0x100a1dff4 (offset from func start: 0x10edec)
preceding instructions in block:
0x100a1dff4: ldr x8, [x19, #0x11c8]
0x100a1dff8: ldr q0, [x8, x11]
base register = x8, index register = x11
--- tracing base register x8 ---
[block 0x100a1dff4] 0x100a1dff4: ldr x8, [x19, #0x11c8]
--- tracing index register x11 ---
[block 0x100a1dff4] -> pred block 0x100a1dfc4 via cff
[block 0x100a1dfc4] -> pred block 0x10096f5f8 via b
[block 0x10096f5f8] 0x10096f604: mov x11, #0
[block 0x100a1dfc4] -> pred block 0x100a1e064 via b
[block 0x100a1e064] -> pred block 0x100a1dff4 via fallthrough
[block 0x100a1dff4] 0x100a1e05c: cmp x11, #0x20
[block 0x100a1dff4] -> pred block 0x100a1dff0 via fallthrough
[block 0x100a1dff0] UNRESOLVED(no predecessors found)
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import lief, bisect, sys, struct
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import data, SEGMENTS
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
BASE = 0x100000000
starts = sorted(BASE + f for f in b.function_starts.functions)
FUNC_START = 0x100783fc8
FUNC_END = 0x1007d594c
def containing_func(va):
idx = bisect.bisect_right(starts, va) - 1
fs = starts[idx]
fe = starts[idx + 1] if idx + 1 < len(starts) else None
return fs, fe
def find_bl_callers(target):
vaddr, vsize, fileoff, filesize, name = SEGMENTS[0]
d = data()
n = filesize // 4
out = []
for i in range(n):
off = fileoff + i * 4
word = struct.unpack_from('<I', d, off)[0]
if (word >> 26) != 0b100101:
continue
imm26 = word & 0x3FFFFFF
if imm26 & 0x2000000:
imm26 -= 0x4000000
va = vaddr + i * 4
t = va + (imm26 << 2)
if t == target:
out.append(va)
return out
out_lines = []
fs, fe = containing_func(0x1007f17fc)
out_lines.append(f'0x1007f17fc belongs to function {hex(fs)}..{hex(fe)} size={hex(fe-fs) if fe else None}')
out_lines.append(f' is this inside initGenAudioH body ({hex(FUNC_START)}..{hex(FUNC_END)})? {FUNC_START <= 0x1007f17fc < FUNC_END}')
callers2 = find_bl_callers(fs)
out_lines.append(f'callers of function {hex(fs)} (which itself calls the brk-func 0x1007d94d0): {len(callers2)}')
for c in callers2[:30]:
cfs, cfe = containing_func(c)
in_body = FUNC_START <= c < FUNC_END
out_lines.append(f' bl-site {hex(c)} in function {hex(cfs)} in_body={in_body}')
with open('/tmp/check_brk_caller_chain_out.txt', 'w') as f:
f.write('\n'.join(out_lines) + '\n')
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import sys, struct
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_u64, read_bytes, BASE
def decode_rebase(raw):
# DYLD_CHAINED_PTR_64 generic rebase: target is low 36 bits (target VMaddr from... )
# bit63=bind(0), bits[51:62]=next(12), bits[36:43]=high8(unused here), bits[0:35]=target(36 bit)
bind = (raw >> 63) & 1
target36 = raw & 0xFFFFFFFFF # 36 bits
return bind, target36
with open('/tmp/decode_chained_out.txt', 'w') as f:
known_classref = 0x100f26e70
raw = read_u64(known_classref)
f.write(f"raw classref value = {hex(raw)}\n")
bind, t36 = decode_rebase(raw)
f.write(f"bind={bind} target36={hex(t36)} -> VA={hex(BASE + t36)}\n")
f.write(f"expected VoIPClient class @ 0x100f3e468\n\n")
selref_addr = 0x100f1ec30
raw2 = read_u64(selref_addr)
bind2, t36_2 = decode_rebase(raw2)
va2 = BASE + t36_2
f.write(f"selref raw={hex(raw2)} bind={bind2} target36={hex(t36_2)} -> VA={hex(va2)}\n")
s = read_bytes(va2, 64)
if s:
f.write(f" selector string @ {hex(va2)}: {s.split(chr(0).encode())[0]!r}\n")
selref_addr2 = 0x100f1ec38
raw3 = read_u64(selref_addr2)
bind3, t36_3 = decode_rebase(raw3)
va3 = BASE + t36_3
f.write(f"selref2 raw={hex(raw3)} bind={bind3} target36={hex(t36_3)} -> VA={hex(va3)}\n")
s = read_bytes(va3, 64)
if s:
f.write(f" selector2 string @ {hex(va3)}: {s.split(chr(0).encode())[0]!r}\n")
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import lief, sys
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_bytes
BASE = 0x100000000
def decode_rebase(raw):
bind = (raw >> 63) & 1
target36 = raw & 0xFFFFFFFFF
return bind, target36
def read_u64(va):
return int.from_bytes(read_bytes(va, 8), 'little')
def cstr(va, maxlen=128):
data = read_bytes(va, maxlen)
end = data.find(b'\x00')
if end == -1:
end = maxlen
return data[:end]
# candidate ref addresses found near the swizzle functions
refs = {
'e04_ref_1 (0x100e99000+0xd98)': 0x100e99000 + 0xd98,
'e04_ref_2 (0x100f26000+0xd30)': 0x100f26000 + 0xd30,
'947e8_ref_donor (0x100da9000+0xfa1)': 0x100da9000 + 0xfa1,
'947e8_ref_1 (0x100ec2000+0xee0)': 0x100ec2000 + 0xee0,
'947e8_ref_2 (0x100ec3000+0x1c0)': 0x100ec3000 + 0x1c0,
'947e8_ref_3 (0x100ec3000+0x20)': 0x100ec3000 + 0x20,
'947e8_ref_4 (0x100ec6000+0x780)': 0x100ec6000 + 0x780,
}
with open('/tmp/swizzle_refs_out.txt', 'w') as f:
for name, addr in refs.items():
raw = read_u64(addr)
bind, target36 = decode_rebase(raw)
va = BASE + target36
if bind:
f.write(f"{name} @ {hex(addr)}: raw={hex(raw)} BIND (import) target36={hex(target36)}\n")
continue
s = cstr(va)
f.write(f"{name} @ {hex(addr)}: raw={hex(raw)} -> VA={hex(va)} str={s!r}\n")
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_bytes, read_u64, BASE, SEGMENTS
def decode_rebase(raw):
bind = (raw >> 63) & 1
target36 = raw & 0xFFFFFFFFF
return bind, target36
def cstr(va, maxlen=200):
if va is None:
return None
data = read_bytes(va, maxlen)
if data is None:
return None
end = data.find(b'\x00')
if end == -1:
end = maxlen
return data[:end]
def which_seg(va):
for vaddr, vsize, fileoff, filesize, name in SEGMENTS:
if vaddr <= va < vaddr + vsize:
return name
return '???'
out = []
# --- direct literal string addresses (adrp+add used directly as pointer arg, no extra deref) ---
direct_strs = {
'947e8_x2_donor_msg (0x100da9000+0xfa1)': 0x100da9000 + 0xfa1,
'947e8_x4_filename (0x100ec6000+0x780)': 0x100ec6000 + 0x780,
'e04_x2_filename (0x100ec3000+0x20)': 0x100ec3000 + 0x20,
}
out.append('=== direct literal C-strings ===')
for name, addr in direct_strs.items():
s = cstr(addr)
out.append(f'{name} @ {hex(addr)} seg={which_seg(addr)}: {s!r}')
# --- pointer-table slots (ldr [addr]) requiring chained-fixup decode ---
ptr_slots = {
'e04_x20_classref? (0x100e99000+0xd98)': 0x100e99000 + 0xd98,
'e04_x0_selref? (0x100f26000+0xd30)': 0x100f26000 + 0xd30,
}
out.append('')
out.append('=== chained-fixup pointer slots ===')
for name, addr in ptr_slots.items():
raw = read_u64(addr)
if raw is None:
out.append(f'{name} @ {hex(addr)}: UNREADABLE')
continue
bind, t36 = decode_rebase(raw)
va = BASE + t36
out.append(f'{name} @ {hex(addr)}: raw={hex(raw)} bind={bind} -> VA={hex(va)} seg={which_seg(va)}')
if bind:
continue
# dump first 64 bytes raw hex + try cstr
raw_bytes = read_bytes(va, 64)
s = cstr(va)
out.append(f' raw64={raw_bytes.hex() if raw_bytes else None}')
out.append(f' cstr={s!r}')
# try treating content as ANOTHER chained pointer (double indirection, e.g. classref->class_t)
q0 = read_u64(va)
if q0 is not None:
b2, t2 = decode_rebase(q0)
va2 = BASE + t2
out.append(f' first_qword={hex(q0)} as-chainedptr-> bind={b2} VA={hex(va2)} seg={which_seg(va2)}')
with open('/tmp/swizzle_refs2_out.txt', 'w') as f:
f.write('\n'.join(out) + '\n')
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_bytes, BASE
import capstone
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
def dump(va, size):
b = read_bytes(va, size)
print(f"--- dump @ {hex(va)} size={hex(size)} ---")
for insn in md.disasm(b, va):
print(f"0x{insn.address:x}:\t{insn.mnemonic}\t{insn.op_str}")
# the tiny function containing target
dump(0x10007d5ac, 0x18)
print()
dump(0x10007d5bc, 0x10)
print()
dump(0x10007d5c4, 0x44)
print("=== objc stub 0x100c40240 full ===")
dump(0x100c40240, 0x30)
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Scan __DATA_CONST + __DATA for ALL chained-fixup pointer slots whose rebase target
equals the 'initGenAudioH' selector string address (0x100d60d7a), to find every selref
to this selector anywhere in the binary (not just the one already found)."""
import sys, struct
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import data, SEGMENTS, BASE
TARGET_STR_VA = 0x100d60d7a
d = data()
results = []
for vaddr, vsize, fileoff, filesize, name in SEGMENTS:
if name not in ('__DATA_CONST', '__DATA'):
continue
n = filesize // 8
for i in range(n):
off = fileoff + i * 8
raw = struct.unpack_from('<Q', d, off)[0]
bind = (raw >> 63) & 1
if bind:
continue
target36 = raw & 0xFFFFFFFFF
va = BASE + target36
if va == TARGET_STR_VA:
slot_va = vaddr + i * 8
results.append((slot_va, name))
with open('/tmp/all_selrefs_out.txt', 'w') as f:
f.write(f'total selref slots pointing to "initGenAudioH" string @ {hex(TARGET_STR_VA)}: {len(results)}\n')
for slot_va, seg in results:
f.write(f' {hex(slot_va)} (seg {seg})\n')
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Find all `bl <target>` call sites for a given target VA, across the whole __TEXT segment
(manual encoding decode, does not rely on r2/capstone's control-flow recovery which fails on
CFF-obscured functions with indirect `br` dispatch)."""
import sys, struct
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import data, SEGMENTS
TARGETS = [0x1007d94d0, 0x1007d594c] # the func containing the 2 brk sites; the func right after initGenAudioH body
vaddr, vsize, fileoff, filesize, name = SEGMENTS[0]
assert name == '__TEXT'
d = data()
n = filesize // 4
results = {t: [] for t in TARGETS}
targets_set = set(TARGETS)
for i in range(n):
off = fileoff + i * 4
word = struct.unpack_from('<I', d, off)[0]
if (word >> 26) != 0b100101:
continue
imm26 = word & 0x3FFFFFF
if imm26 & 0x2000000:
imm26 -= 0x4000000
va = vaddr + i * 4
target = va + (imm26 << 2)
if target in targets_set:
results[target].append(va)
with open('/tmp/bl_callers_out.txt', 'w') as f:
for t, callers in results.items():
f.write(f'target={hex(t)}: {len(callers)} callers\n')
for c in callers[:50]:
f.write(f' bl-site {hex(c)}\n')
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Locate the function containing a given VA using LC_FUNCTION_STARTS."""
import lief
import sys
BINARY = '/home/vscode/app/targets/znca_ios_3.4.1/Crew'
BASE = 0x100000000
b = lief.parse(BINARY)
print("format:", b.format)
fs = b.function_starts
print("function_starts object:", fs)
addrs = sorted(fs.functions) if fs is not None else []
print("num function starts:", len(addrs))
target = 0x10007D5B4
# functions are stored as offsets from base typically already VA in LIEF? check first few
print("first 5 raw:", addrs[:5])
print("last 5 raw:", addrs[-5:])
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import lief, bisect
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
BASE = 0x100000000
starts = sorted(BASE + f for f in b.function_starts.functions)
targets = [0x1007d98b0, 0x1007d9a68, 0x100783fc8, 0x1007d594c, 0x100a29528]
with open('/tmp/locate_brk_funcs_out.txt', 'w') as f:
for t in targets:
idx = bisect.bisect_right(starts, t) - 1
func_start = starts[idx]
func_end = starts[idx + 1] if idx + 1 < len(starts) else None
f.write(f'{hex(t)} -> containing function start={hex(func_start)} end(next start)={hex(func_end) if func_end else None} size={hex(func_end-func_start) if func_end else None}\n')
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
import sys, struct
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_bytes, read_u64
with open('/tmp/read_refs_out.txt', 'w') as f:
# selref at 0x100f1e000+0xc30
selref_addr = 0x100f1e000 + 0xc30
selref_ptr = read_u64(selref_addr)
f.write(f"selref @ {hex(selref_addr)} -> {hex(selref_ptr) if selref_ptr else None}\n")
if selref_ptr:
s = read_bytes(selref_ptr, 64)
if s:
z = s.split(b'\x00')[0]
f.write(f" selector string: {z!r}\n")
# class ref at 0x100f26000+0xe70
classref_addr = 0x100f26000 + 0xe70
classref_ptr = read_u64(classref_addr)
f.write(f"classref @ {hex(classref_addr)} -> {hex(classref_ptr) if classref_ptr else None}\n")
# second selref pair at 0x100c40260 (0xc38)
selref_addr2 = 0x100f1e000 + 0xc38
selref_ptr2 = read_u64(selref_addr2)
f.write(f"selref2 @ {hex(selref_addr2)} -> {hex(selref_ptr2) if selref_ptr2 else None}\n")
if selref_ptr2:
s = read_bytes(selref_ptr2, 64)
if s:
z = s.split(b'\x00')[0]
f.write(f" selector2 string: {z!r}\n")
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import lief
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
targets = [0x100c34fbc, 0x100c35004, 0x100c34470, 0x100c40240]
# Build reloc/bind map: address -> symbol name
# Use lief's dyld_info / chained fixups
print("=== bindings (dyld_chained_fixups) ===")
try:
for b_ in b.dyld_chained_fixups.bindings:
pass
except Exception as e:
print("no chained_fixups bindings attr directly, trying alternate", e)
# Alternate: use imported functions / symbols with address
print("=== imported functions ===")
imp_addrs = {}
for f in b.imported_functions:
imp_addrs[f.name] = f
print(len(b.imported_functions), "imported functions")
# use lief's Binary.get_function_address is for exported; try symtab search for STUBS section entries
print("=== sections named __stubs / __auth_stubs ===")
for seg in b.segments:
for sec in seg.sections:
if 'stub' in sec.name.lower():
print(seg.name, sec.name, hex(sec.virtual_address), hex(sec.size))
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import lief
import capstone, sys
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import read_bytes
b = lief.parse('/home/vscode/app/targets/znca_ios_3.4.1/Crew')
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
def got_slot_for_stub(stub_va):
code = read_bytes(stub_va, 12)
insns = list(md.disasm(code, stub_va))
adrp = insns[0]; ldr = insns[1]
page = adrp.operands[1].imm
off = ldr.operands[1].mem.disp
return page + off
targets = [0x100c34fbc, 0x100c35004, 0x100c34470, 0x100c40240]
slots = {}
for t in targets:
g = got_slot_for_stub(t)
slots[t] = g
addr2sym = {}
for bnd in b.bindings:
try:
addr2sym[bnd.address] = bnd.symbol.name if bnd.symbol else None
except Exception:
pass
with open('/tmp/resolve_stub2_out.txt', 'w') as f:
f.write(f"total bindings: {len(addr2sym)}\n")
for t, g in slots.items():
f.write(f"{hex(t)} GOT {hex(g)} -> sym: {addr2sym.get(g, 'NOT FOUND')}\n")
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Scan the ENTIRE Crew binary (all executable segments) for BRK #imm instructions.
BRK encoding (A64): bits [31:21] = 1101 0100 001, bits [4:0] = 00000
mask 0xFFE0001F == 0xD4200000, imm16 in bits [20:5].
"""
import sys, struct
sys.path.insert(0, '/home/vscode/app/targets/znca_ios_3.4.1/analysis/cff')
from common import data, SEGMENTS, BASE
FUNC_START = 0x100783fc8
FUNC_END = 0x1007d594c
WRAPPER_START = 0x100a29528
WRAPPER_END = 0x100a29528 + 0x7c
d = data()
results = []
# only scan __TEXT (0x100000000, size 0xe54000) since that's the only executable segment
vaddr, vsize, fileoff, filesize, name = SEGMENTS[0]
assert name == '__TEXT'
n = filesize // 4
for i in range(n):
off = fileoff + i * 4
word = struct.unpack_from('<I', d, off)[0]
if (word & 0xFFE0001F) == 0xD4200000:
imm16 = (word >> 5) & 0xFFFF
va = vaddr + i * 4
results.append((va, imm16))
with open('/tmp/all_brk_out.txt', 'w') as f:
f.write(f'total BRK instructions found in __TEXT: {len(results)}\n\n')
for va, imm16 in results:
in_body = FUNC_START <= va < FUNC_END
in_wrapper = WRAPPER_START <= va < WRAPPER_END
tag = 'INSIDE initGenAudioH body' if in_body else ('INSIDE wrapper' if in_wrapper else '')
f.write(f'{hex(va)} brk #{hex(imm16)} {tag}\n')
@@ -0,0 +1,25 @@
"""Shared constants for znca iOS 3.4.1 whitebox analysis tools.
All tools operate on the extracted __DATA.__data slice:
targets/znca_ios_3.4.1/whitebox/whitebox_data_0x100f70000_0x101192000.bin
which corresponds to Crew (arm64 Mach-O, __TEXT vmaddr=0x100000000) VA range
0x100f70000 .. 0x101192000 (2236416 bytes / 2.133 MiB), file offset 0xf70000.
"""
from pathlib import Path
WHITEBOX_DIR = Path(__file__).resolve().parent.parent
BIN_PATH = WHITEBOX_DIR / "whitebox_data_0x100f70000_0x101192000.bin"
ANALYSIS_DIR = WHITEBOX_DIR / "analysis"
VA_LO = 0x100F70000
VA_HI = 0x101192000
CREW_PATH = WHITEBOX_DIR.parent / "Crew"
assert VA_HI - VA_LO == 2236416
def load_bin() -> bytes:
return BIN_PATH.read_bytes()
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Reproduce distinct_rows_1024.npy — distinct 4-byte "row" count per 1024B block.
Interpretation: each non-overlapping 1024-byte block is reshaped as a
256 x 4 byte matrix (256 rows of 4 bytes = one machine word each), and the
number of *distinct* rows (as 4-byte tuples) is counted.
Rationale for row width = 4: verified empirically — this is the only row
width in {4, 8, 16, 32, 64} that reproduces the saved npy's max value of
exactly 256 (== number of rows only when row width is 4, since
1024 / 4 = 256 rows). This granularity is consistent with scanning for
repeated 32-bit table entries (round-key words / T-box column entries are
typically u32-sized in Chow/TFIT-style whiteboxes).
min=136 in the saved data means some 1024B blocks have heavy 4-byte-word
repetition (candidate: sparse/structured regions, padding, or zero-heavy
areas), while most blocks (mean=253.45) are close to fully distinct
(consistent with high-entropy round-table data where collisions among
256 random 4-byte words are still statistically likely to leave a handful
of duplicates - birthday-bound expectation for 256 draws from 2^32 space
is ~0 collisions, so any many-collision block indicates non-random content).
Usage:
python3 gen_distinct_rows_1024.py [--verify]
"""
import argparse
import sys
import numpy as np
from common import ANALYSIS_DIR, load_bin
BLOCK = 1024
ROWLEN = 4
ROWS = BLOCK // ROWLEN # 256
def compute(data: bytes) -> np.ndarray:
assert len(data) % BLOCK == 0
n_blocks = len(data) // BLOCK
arr = np.frombuffer(data, dtype=np.uint8).reshape(n_blocks, ROWS, ROWLEN)
out = np.empty(n_blocks, dtype=np.int32)
for i, block in enumerate(arr):
# view rows as opaque 4-byte tuples for uniqueness counting
out[i] = len({row.tobytes() for row in block})
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--verify", action="store_true")
ap.add_argument("--out", default=str(ANALYSIS_DIR / "distinct_rows_1024.npy"))
args = ap.parse_args()
data = load_bin()
result = compute(data)
print(
f"computed {result.shape[0]} blocks, min={result.min()} max={result.max()} "
f"mean={result.mean():.2f}"
)
if args.verify:
saved = np.load(ANALYSIS_DIR / "distinct_rows_1024.npy")
if np.array_equal(result, saved):
print("VERIFY OK: exact match with saved distinct_rows_1024.npy")
else:
mismatches = np.flatnonzero(result != saved)
print(f"VERIFY MISMATCH: {len(mismatches)} blocks differ, first={mismatches[:10]}")
sys.exit(1)
else:
np.save(args.out, result)
print(f"saved to {args.out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Reproduce entropy_256.npy — Shannon entropy over non-overlapping 256B windows.
whitebox blob size = 2236416 bytes -> 2236416 / 256 = 8736 windows exactly,
matching the saved entropy_256.npy shape (8736,).
For each 256-byte block, computes Shannon entropy in bits/byte:
H = -sum(p_i * log2(p_i)) for each byte value 0..255 present in the block.
Usage:
python3 gen_entropy_256.py [--verify]
"""
import argparse
import math
import sys
import numpy as np
from common import ANALYSIS_DIR, load_bin
WINDOW = 256
def shannon_entropy(block: bytes) -> float:
if not block:
return 0.0
counts = np.bincount(np.frombuffer(block, dtype=np.uint8), minlength=256)
n = len(block)
probs = counts[counts > 0] / n
return float(-(probs * np.log2(probs)).sum())
def compute(data: bytes) -> np.ndarray:
n_windows = len(data) // WINDOW
assert len(data) % WINDOW == 0, "blob size must be a multiple of 256"
out = np.empty(n_windows, dtype=np.float64)
for i in range(n_windows):
block = data[i * WINDOW : (i + 1) * WINDOW]
out[i] = shannon_entropy(block)
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--verify", action="store_true", help="compare against saved npy")
ap.add_argument("--out", default=str(ANALYSIS_DIR / "entropy_256.npy"))
args = ap.parse_args()
data = load_bin()
result = compute(data)
print(f"computed {result.shape[0]} windows, min={result.min():.4f} max={result.max():.4f} mean={result.mean():.4f}")
if args.verify:
saved = np.load(ANALYSIS_DIR / "entropy_256.npy")
if np.allclose(result, saved):
print("VERIFY OK: matches saved entropy_256.npy exactly (within float tolerance)")
else:
diff = np.abs(result - saved)
print(f"VERIFY MISMATCH: max abs diff = {diff.max()} at index {diff.argmax()}")
sys.exit(1)
else:
np.save(args.out, result)
print(f"saved to {args.out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Segment the 2.13 MiB whitebox blob into "pure table" vs "CFF plumbing"
regions using the chained-fixup pointer mask, and report aggregate
entropy / distinct-row statistics per segment class.
A "CFF-plumbing island" is a maximal run of 4K bins (with up to 2
consecutive zero-fixup bins tolerated as a gap-merge) that contains at
least one chained-fixup rebase site. These correspond to
control-flow-flattening dispatch-pointer clusters shared by multiple
OLLVM-obfuscated functions (initGenAudioH, _gen_audio_h, _gen_audio_h2,
etc.) that the linker packed into the same __DATA.__data VA range as the
genuine whitebox constant tables — see znca_ios_whitebox_layout.md for
the full analysis and evidence.
Requires pointer_fixup_per_4k.npy (from gen_pointer_fixups.py --save),
entropy_256.npy, distinct_rows_1024.npy (from gen_entropy_256.py /
gen_distinct_rows_1024.py).
Usage:
python3 gen_layout_segments.py
"""
import numpy as np
from common import ANALYSIS_DIR, VA_LO
GAP_TOLERANCE = 2 # consecutive zero-fixup bins allowed inside an island
def find_islands(nz: np.ndarray) -> list[tuple[int, int]]:
islands = []
n = len(nz)
i = 0
while i < n:
if not nz[i]:
i += 1
continue
start = i
last_true = i
j = i + 1
gap = 0
while j < n:
if nz[j]:
last_true = j
gap = 0
else:
gap += 1
if gap > GAP_TOLERANCE:
break
j += 1
islands.append((start, last_true))
i = last_true + 1
return islands
def main() -> None:
entropy = np.load(ANALYSIS_DIR / "entropy_256.npy") # (8736,) 256B windows
distinct = np.load(ANALYSIS_DIR / "distinct_rows_1024.npy") # (2184,) 1024B blocks
fixup_per_4k = np.load(ANALYSIS_DIR / "pointer_fixup_per_4k.npy") # (546,)
n_bins = len(fixup_per_4k)
ent_per_4k = entropy.reshape(n_bins, 16).mean(axis=1)
dist_per_4k = distinct.reshape(n_bins, 4).mean(axis=1)
nz = fixup_per_4k > 0
islands = find_islands(nz)
island_bins = set()
for s, e in islands:
island_bins.update(range(s, e + 1))
print(f"CFF-plumbing islands (gap tolerance={GAP_TOLERANCE} bins): {len(islands)}")
print(f"{'bins':>12} {'VA range':>25} {'size':>8} {'fixups':>7} {'entropy':>8} {'distinct':>9}")
total_island_bytes = 0
for s, e in islands:
va_lo = VA_LO + s * 4096
va_hi = VA_LO + (e + 1) * 4096
size = va_hi - va_lo
total_island_bytes += size
tot_fixups = int(fixup_per_4k[s : e + 1].sum())
me = ent_per_4k[s : e + 1].mean()
md = dist_per_4k[s : e + 1].mean()
print(
f"[{s:4d}:{e:4d}] 0x{va_lo:x}-0x{va_hi:x} 0x{size:<6x} {tot_fixups:7d} "
f"{me:8.3f} {md:9.1f}"
)
pure_bins = [i for i in range(n_bins) if i not in island_bins]
pure_bytes = len(pure_bins) * 4096
pure_entropy = ent_per_4k[pure_bins]
pure_distinct = dist_per_4k[pure_bins]
print(f"\nCFF-plumbing total: {len(island_bins)}/{n_bins} bins, {total_island_bytes} bytes "
f"({total_island_bytes / 1024:.1f} KiB, {100 * total_island_bytes / (n_bins * 4096):.1f}%)")
print(f"Pure-table total: {len(pure_bins)}/{n_bins} bins, {pure_bytes} bytes "
f"({pure_bytes / 1024 / 1024:.2f} MiB, {100 * pure_bytes / (n_bins * 4096):.1f}%)")
print(f" pure-table entropy: min={pure_entropy.min():.3f} max={pure_entropy.max():.3f} "
f"mean={pure_entropy.mean():.3f}")
print(f" pure-table distinct-4B-rows/1024B: min={pure_distinct.min():.1f} "
f"max={pure_distinct.max():.1f} mean={pure_distinct.mean():.1f}")
if __name__ == "__main__":
main()
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Reproduce perm_cols_1024.npy — count of "permutation columns" per 1024B block.
Interpretation (reconstructed): each 1024-byte block is reshaped as a
32x32 byte matrix (row-major). For each of the 32 columns (32 bytes each),
check whether the column, once sorted, equals exactly [0, 1, ..., 31]
(i.e. the column is literally a permutation of the 5-bit index space
0..31). Count how many of the 32 columns satisfy this per block.
CAVEAT (documented in znca_ios_whitebox_layout.md): this criterion is
essentially impossible to satisfy on real table data because byte values
are drawn from 0..255, not 0..31 — a column would need all 32 bytes to
land in the narrow 0..31 range AND be a complete bijection, which for
near-random high-entropy data has probability ~32!/32^32 ~ 1e-14 per
column. This is almost certainly why the saved npy is all zero: the
original probe was testing for a "5-bit permutation table" structure
(e.g. an S5-style substitution box embedded byte-aligned in row-major
32x32 layout) and found none. It does NOT prove no permutation structure
exists — only that it doesn't exist in this specific (32x32, mod-32,
row-major) framing. A follow-up should retry with:
- permutation of 0..255 (full byte permutation, i.e. is this 1024B
block itself a bijection __DATA -> __DATA, columns are irrelevant)
- column-major instead of row-major
- narrower row shapes (e.g. 4x256, 8x128) matching AES T-box strides
Usage:
python3 gen_perm_cols_1024.py [--verify]
"""
import argparse
import sys
import numpy as np
from common import ANALYSIS_DIR, load_bin
BLOCK = 1024
DIM = 32 # 32x32 byte matrix
def compute(data: bytes) -> np.ndarray:
assert len(data) % BLOCK == 0
n_blocks = len(data) // BLOCK
arr = np.frombuffer(data, dtype=np.uint8).reshape(n_blocks, DIM, DIM)
target = np.arange(DIM, dtype=np.uint8)
out = np.zeros(n_blocks, dtype=np.int32)
for i in range(n_blocks):
cols = arr[i].T
sorted_cols = np.sort(cols, axis=1)
out[i] = int(np.all(sorted_cols == target, axis=1).sum())
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--verify", action="store_true")
ap.add_argument("--out", default=str(ANALYSIS_DIR / "perm_cols_1024.npy"))
args = ap.parse_args()
data = load_bin()
result = compute(data)
print(f"computed {result.shape[0]} blocks, min={result.min()} max={result.max()} sum={result.sum()}")
if args.verify:
saved = np.load(ANALYSIS_DIR / "perm_cols_1024.npy")
if np.array_equal(result, saved):
print("VERIFY OK: exact match (both all-zero)")
else:
sys.exit(1)
else:
np.save(args.out, result)
print(f"saved to {args.out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Ground-truth chained-fixup (rebase) pointer scan over the whitebox VA range.
This supersedes the lost rebase_per_4k.npy / chain_per_4k.npy / chain_mask.npy
from the previous session. Those three arrays do not sum to a count that is
reproducible from any single well-defined chain-walk of the real
LC_DYLD_CHAINED_FIXUPS data (see znca_ios_whitebox_layout.md for the
discrepancy analysis: legacy sum = 69 + 2149 = 2218 vs. ground truth = 2775),
so rather than guess the exact (probably buggy/partial) legacy algorithm,
this script re-derives the fixup locations authoritatively via LIEF's
Mach-O chained-fixups parser (which walks the real page_start + next-delta
linked lists per Apple's dyld3 chained-fixups format) and produces:
- pointer_fixup_per_4k.npy (546,) int64 — fixup count per 4 KiB bin
- pointer_fixup_mask.npy (279552,) bool — True at each 8-byte-aligned
slot index that is a real
chained-fixup rebase site
- pointer_fixup_targets.npy (N, 2) int64 — (address, target) VA pairs
for every fixup found,
N == pointer_fixup_mask.sum()
All addresses are whitebox-blob-relative unless noted (VA - VA_LO).
Usage:
python3 gen_pointer_fixups.py [--save]
"""
import argparse
import lief
import numpy as np
from common import ANALYSIS_DIR, CREW_PATH, VA_HI, VA_LO
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--save", action="store_true")
args = ap.parse_args()
b = lief.parse(str(CREW_PATH))
relocs = list(b.relocations)
in_range = [r for r in relocs if VA_LO <= r.address < VA_HI]
n_slots = (VA_HI - VA_LO) // 8
n_bins = (VA_HI - VA_LO) // 4096
mask = np.zeros(n_slots, dtype=bool)
per_4k = np.zeros(n_bins, dtype=np.int64)
pairs = []
for r in in_range:
slot = (r.address - VA_LO) // 8
mask[slot] = True
per_4k[(r.address - VA_LO) // 4096] += 1
pairs.append((r.address, r.target))
targets = np.array(pairs, dtype=np.int64)
print(f"total chained-fixup rebase sites in whitebox range: {len(in_range)}")
print(f" all origin=CHAINED_FIXUPS, has_symbol=False (pure rebase, no bind): "
f"{sum(1 for r in in_range if not r.has_symbol)}")
print(f" targets pointing back into whitebox range: "
f"{sum(1 for _, t in pairs if VA_LO <= t < VA_HI)}")
print(f" targets pointing outside whitebox range: "
f"{sum(1 for _, t in pairs if not (VA_LO <= t < VA_HI))}")
print(f"per-4K bin: nonzero={np.count_nonzero(per_4k)}/{n_bins}, max={per_4k.max()}")
top = np.argsort(per_4k)[::-1][:15]
print("\ntop 15 densest 4K bins:")
for i in top:
if per_4k[i] == 0:
break
print(f" bin {i:4d} VA=0x{VA_LO + i * 4096:x} count={per_4k[i]}")
if args.save:
np.save(ANALYSIS_DIR / "pointer_fixup_per_4k.npy", per_4k)
np.save(ANALYSIS_DIR / "pointer_fixup_mask.npy", mask)
np.save(ANALYSIS_DIR / "pointer_fixup_targets.npy", targets)
print(f"\nsaved to {ANALYSIS_DIR}")
if __name__ == "__main__":
main()
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Scan _gen_audio_h (Crew VA 0x10090f20c .. 0x100a208fc) for data references.
Follows the same ADRP+ADD/LDR tracking method used for initGenAudioH in
docs/spec/znca_ios_init_gen_audio_h_static.md ("categorize_refs.py"), plus
an explicit pass for LDRB (register)-indexed table-lookup instructions
(the classic whitebox S-box access pattern: `ldrb wD, [Xbase, Windex, uxtw]`).
Buckets every resolved data target address by which whitebox-layout section
it falls into (per docs/spec/znca_ios_whitebox_layout.md), to cross-check
that _gen_audio_h really dereferences the "pure" (no-chained-fixup) high
entropy region and not the CFF-plumbing / pointer-fixup pages.
Usage:
python3 scan_gen_audio_h_refs.py
"""
import struct
from collections import Counter, defaultdict
import capstone
from common import ANALYSIS_DIR, CREW_PATH, VA_HI, VA_LO
FUNC_LO = 0x10090F20C
FUNC_HI = 0x100A208FC # end of _gen_audio_h .. _gen_audio_h2 tail-merged region
TEXT_VA_BASE = 0x100000000
def read_func_bytes() -> bytes:
data = CREW_PATH.read_bytes()
file_off = FUNC_LO - TEXT_VA_BASE
size = FUNC_HI - FUNC_LO
return data[file_off : file_off + size]
def classify(addr: int) -> str:
if VA_LO <= addr < VA_HI:
return "whitebox_blob"
if 0x100E54000 <= addr < 0x100EDC000:
return "__DATA_CONST"
if 0x100EDC000 <= addr < 0x1013E8000:
return "__DATA (outside whitebox blob)"
if 0x100000000 <= addr < 0x100E54000:
return "__TEXT"
if 0x1013E8000 <= addr < 0x101458000:
return "__ETC"
return "other/unknown"
def main() -> None:
code = read_func_bytes()
md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
md.detail = True
adrp_targets: dict[int, int] = {} # reg -> page target (accumulated per adrp then add)
data_refs: Counter[int] = Counter()
ldrb_reg_insns: list[tuple[int, str]] = []
ldr_indexed_insns: list[tuple[int, str]] = []
pending_adrp: dict[int, int] = {} # reg number -> page base
insn_count = 0
for insn in md.disasm(code, FUNC_LO):
insn_count += 1
mnem = insn.mnemonic
ops = insn.op_str
if mnem == "adrp":
# adrp xN, #imm -> operands: reg, imm(page-aligned target)
try:
reg = insn.reg_name(insn.operands[0].reg)
imm = insn.operands[1].imm
pending_adrp[reg] = imm
except Exception:
pass
continue
if mnem == "add" and len(insn.operands) == 3:
try:
dst = insn.reg_name(insn.operands[0].reg)
src = insn.reg_name(insn.operands[1].reg)
if src in pending_adrp and insn.operands[2].type == capstone.arm64.ARM64_OP_IMM:
target = pending_adrp[src] + insn.operands[2].imm
data_refs[target] += 1
pending_adrp[dst] = target # propagate in case chained
except Exception:
pass
continue
if mnem.startswith("ldrb") and "[" in ops:
# true register-indexed byte load: ldrb wD, [Xn, Wm, uxtw] or [Xn, Xm]
# (as opposed to immediate-offset struct field access [Xn, #imm])
inner = ops.split("[", 1)[1]
if "," in inner and "#" not in inner:
ldrb_reg_insns.append((insn.address, f"{mnem} {ops}"))
continue
if mnem in ("ldr", "ldrh", "ldrsb", "ldrsh") and "[" in ops:
inner = ops.split("[", 1)[1]
if "," in inner and "#" not in inner:
ldr_indexed_insns.append((insn.address, f"{mnem} {ops}"))
print(f"disassembled {insn_count} instructions over {len(code)} bytes")
print(f"distinct ADRP+ADD data targets: {len(data_refs)}, total refs: {sum(data_refs.values())}")
by_section: Counter[str] = Counter()
for addr, cnt in data_refs.items():
by_section[classify(addr)] += cnt
print("\nADRP+ADD targets by section:")
for sec, cnt in by_section.most_common():
print(f" {sec:35s} {cnt:6d}")
print(f"\nregister-indexed LDRB instructions (S-box-style byte table lookup): {len(ldrb_reg_insns)}")
for addr, txt in ldrb_reg_insns[:30]:
print(f" 0x{addr:x} {txt}")
print(f"\nother register-indexed load instructions: {len(ldr_indexed_insns)}")
for addr, txt in ldr_indexed_insns[:30]:
print(f" 0x{addr:x} {txt}")
# top data targets within whitebox blob specifically
wb_targets = {a: c for a, c in data_refs.items() if VA_LO <= a < VA_HI}
print(f"\ndistinct whitebox-blob targets referenced: {len(wb_targets)}")
top = sorted(wb_targets.items(), key=lambda kv: -kv[1])[:30]
for addr, cnt in top:
off = addr - VA_LO
print(f" 0x{addr:x} (blob+0x{off:06x}) refs={cnt}")
import pickle
with (ANALYSIS_DIR / "gen_audio_h_data_refs.pkl").open("wb") as f:
pickle.dump(
{
"data_refs": dict(data_refs),
"ldrb_reg_insns": ldrb_reg_insns,
"ldr_indexed_insns": ldr_indexed_insns,
},
f,
)
print(f"\nsaved raw results to {ANALYSIS_DIR / 'gen_audio_h_data_refs.pkl'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,7 @@
Source: Crew binary, znca iOS 3.4.1
VA range: 0x100f70000 .. 0x101192000
File offset: 0xf70000
Size: 2236416 bytes (2.133 MiB)
Segment: __DATA
Purpose: whitebox crypto tables (T-box / round key / permutation) for _gen_audio_h/h2
Notes: Loaded via Mach-O mmap (not by initGenAudioH). All rounds appear TFIT-encoded (no raw AES SBox).