起点
ある日の現実の問題:手元に macOS Universal Mach-O App があり、ソースコードも、開発者証明書も、サーバーへのアクセス権限もない前提で、それを動かす必要があった。こういう場面は、実際の攻防ではそれほど珍しくない — ひとつのバイナリを手に入れ、その内部の判断経路を理解し、意味を変えない前提で、ある分岐を自分の望む方向へ進ませたい、という状況だ。
以下では、変更された App が何だったのかは扱わず、方法だけを扱う。すべての trick は、日常のセキュリティ監査、debug、グレーボックステストにそのまま応用できる。
前提:本稿では、あなたがそのバイナリに対して**合法なリバース権**を持っていることを想定している — 自分の製品、CTF の課題、許可を得たセキュリティ監査対象、またはすでに公開終了しているが購入したことのある古いソフトウェアなどだ。この技術を他人の商用製品に向けることは、この記事の想定する場面ではない。
ネタバレ:この実戦の 8 時間のうち、6 時間はソフトな方法でつまずき、2 時間で最終的な 18 個のパッチの 90% を仕上げた。以下の 5 つの教訓は、この 8 時間の振り返りだ。Ghidra を少なくとも一度は使ったことがあるが、patch を実際に動作する app へ反映したことはまだない、という人に向いている。
最初の教訓:まずソフトな方法を尽くす。ただし、いつ諦めるかを知る
正式にバイナリをいじり始める前に、私は「ソフトな方法」にまるまる数時間を費やした — ユーザー空間の設定を変える、Keychain を変える、hosts を変える、外向きファイアウォールを張る。これらの試みはどれも失敗したが、失敗の仕方はそれぞれ違い、その一つひとつが一つのことを教えてくれた:
- まず crash log を見る:binary に手を入れる前の最初の動作は、逆コンパイルではなく
~/Library/Logs/DiagnosticReports/<app>-*.ipsだ。Dyld の初期エラー(Library not loaded: @rpath/...、Symbol not found、unknown library ordinal -8、bundleIdentifierVerification)は、たいていtermination reasonsとexceptionフィールドにかなり率直に書かれていて、次にフレームワークの整合性、シンボル輸出テーブル、receipt 検証パスのどれを掘るべきかを直接教えてくれる。この一手は下のどのソフトな方法よりも「ソフト」で、ソフトな方法の系譜における第 0 歩であるべきだ。 - plist preferences を変える:feature flag に見える key の多くは、実際には i18n localization key にすぎない。i18n key と本物の状態 key を区別するための識別信号の一つは、app がこの文字列を受け取ったあと、直接表示するのか、それとも判定ブランチに使うのか、という点だ。reflection metadata の中の命名規約を見れば、たいていすぐに見分けられる。
- Keychain entries を偽造する:現代の Swift app は、永続化データに Ed25519 署名を付けるのが一般的だ。秘密鍵がなければ正当な署名は生成できない。entry を書き込んだとしても、ロード段階を過ぎたところで署名検証に落とされ、
hasCachedXxx=falseの形でログに記録される — それでもあなたは、認識されたと思ってしまう。 /etc/hostsでブロックする:Cloudflare WARP / iCloud Private Relay / 何らかのユーザー空間 DNS リゾルバを使っている機械では、/etc/hostsはそもそも参照されない。これは特に覚えておく価値がある。なぜなら、hosts ファイルを見つめながら、なぜ nslookup が本物の IP を返すのかと疑問に思うことになるからだ — 見かけと事実が完全に切り離されている。- 外向きファイアウォール(LuLu):work はするが、移植性がない。別の Mac に替えるとやり直しになる。
教訓は「ソフトな方法は役に立たない」ではない。教訓は — ソフトな方法にはそれぞれ解決できる問題領域があり、目標の検証チェーン全体がこちらで変えられる範囲の外にあるとき、ソフトな方法をいくら重ねてもノイズでしかない、ということだ。このときは迷わずバイナリ層へ切り替えるべきだ。
この臨界点を判断する大まかな信号がある:目標の状態機械がどう動くかはもう把握しているが、どの状態への入口も署名 / サーバー / キャッシュで交差的に守られている、という状況だ。この段階でまだ外側からパッチを当て続けるのは、検証チェーンと競走しているようなものだ。
ソフトな方法を尽くしたあとの判断点を越えたら、次はツールチェーンの選択に入る。
二つ目の教訓:ツールチェーンは積み重ねて使うもので、代替関係ではない
実際にバイナリ層へ入ったあとに使ったツール群:
- Ghidra 11.3.2 headless mode — 主力のデコンパイラ。Swift バイナリは Hopper/IDA にとっても簡単ではないが、Ghidra 内蔵の Swift demangler + decompiler なら、10MB 規模の Mach-O に対する完全な AutoAnalysis はだいたい 7〜8 分で終わる。一度走らせてから、自分の Java スクリプトで解析済みの
.gprproject を何度も query するのがよい。毎回 Analysis を走らせ直すべきではない。 xcrun llvm-objdump --disassemble --start-address=X --stop-address=Y— Ghidra が吐く擬似 C が信用できないときは、asm に戻って見る。具体的には、ある cbnz/tbnz の immediate を spot-check したり、movz/movk で組み立てられたインライン Swift 文字列を確認したりする。xcrun swift-demangle—nmの出力を pipe すれば、_$s10AppName14SomeManagerC...がどの class のどの method かすぐ分かる。Swift は構造情報のあまりにも多くを mangling の中に隠しているので、demangle できないと作業にならない。rabin2 -zz(radare2) — すべての cstring / objc methname / Swift reflstr を vaddr 付きで一括 dump する。strings -aよりずっと強い。lldb -b -s commands.txt— batch mode で patch が本当に実行中の image に入っているかを検証する。Attach +memory read --size 4 --count 1 --format x <addr>で、期待する byte と照合する。これは「ファイルを変えた」ことと「実行中のプロセスが本当に新しいコードを実行している」ことを区別できる唯一の方法だ — 後者では ASLR slide も考慮しなければならない。otool -l/lipo -detailed_info— Universal binary の fat header から arm64 slice の offset を取る。Universal binary の universal-offset と arm64-slice-offset には固定の base 差があり、この値は app ごとに違うため、毎回lipo -detailed_info <bin> | grep -A4 arm64で現物を確認する必要がある(出力のoffsetフィールドがその base)。自分も最初に base を計算し間違えて痛い目を見た。patch は書けたように見えるのに、byte がまったく無関係な場所に落ちていたのだ。これは新人がもっとも踏みやすい罠:スクリプトを書くときは universal offset と slice offset を別々の変数名で厳密に区別する。dyld_info -fixups <bin>/dyld_info -exports <bin>— macOS 12+ に標準で入っている。nmより強いのは、dyld が実際に見ている chained fixups と export trie の視点を教えてくれることだ。nmに見える「export された記号」と、dyld が実際に bind に使う export trie は一致しないことがある(build script が class を半端に rename したときによくある)。これは nm だけでは絶対に見抜けない罠だ。codesign --force --deep --sign -— byte を変えたら必ず再署名する。macOS の sealed-resource 検証は deep mode だとContents/内の小さなファイルまで見に行く。見分け方:codesign --verify --verbose /Applications/<app>.appがsealed resource is missing or invalidを報告したら、エラー情報の中に具体的な path が示される(たいていは内蔵 framework / bundle 資源のどれか)。そのファイルを .app の外へ移動してから sign し、署名後に戻す。app ごとに「違反ファイル」は違うが、特定のパターンは固定だ。hdiutil create -format UDZO— 変更後の .app を DMG に戻し、「別の機械」に配布できる媒体にする。
3つ目の教訓:「キラー補丁」を探せ、すべての callsite を巡回するな
逆向 8 時間の中でいちばん価値があった認識の変化:36 個の callsite にそれぞれ補丁を当てるより、そこから共通して呼ばれる関数を探したほうがいい。
具体的には:badge 描画、modal 表示、feature gate、メニュー項目クリック応答 — 表面上は 36 個の独立した UI / action callsite だ。だがいくつか逆アセンブルすると、どれも同じ pattern を通ることが分かる:
bl <some_fn> ; bitmask を返す
mvn wN, w0 ; 反転
and wN, wN, MASK ; 注目する数 bit を取り出す
cbnz xN, deny_path ; 全部 0 でなければ拒否
違いは MASK だけ。すべての callsite が同じ <some_fn> 判断関数を共有している。このときは、その関数の prologue をそのまま movn x0, #0; ret(0xFFFFFFFFFFFFFFFF を返す)に書き換えれば、すべての callsite が同時に「MASK が完全に一致、通過」と見なす。1 箇所の変更が 36 箇所ぶんになる。
この考え方は防御側でも成り立つ:コードを監査するときは、こういう「全局判断点」関数を見つける。それらは攻撃者にとって最高価値の標的であると同時に、防御で重点的に守るべき対象でもある(多重検証、runtime 完全性、名前の難読化)。
caller-side の安全準則を補足
「callee の prologue を movn x0, #0; ret に変える」この定石は、基本型 / 整数 bitmask を返す関数にしか安全ではない。callee が Swift 関数で retain-counted な class instance / Optional を返すなら、caller はほぼ確実に swift_retain(x0)、bl _objc_release、x0 を log 引数として stack に保存、さらに後続で swift_release_n(x0, #2) などを行う。このとき callee に 0 / nil を返させると、caller はすぐ nil を object として dereference して SIGSEGV になる。
実践準則:
- callee をいじる前に、caller が戻り値をどう使うか見る。
mov x28, x0の直後にbl _swift_retainが続くなら危険信号だ。 - 戻り値が再利用されるなら、caller 側から call 全体を迂回するほうが安全 —
tbnz/cbzを 1 本変えて常に「すでに条件を満たした」分岐へ行かせ、callee がそもそも呼ばれないようにする。あるいは呼ばれても caller が「戻り値を消費しない」path に進ませる。 - callee 入口を直接 ret に変える前提条件は:callee がもともと void-ish な戻りであること、または caller が x0 に対して retain / release / dereference をしないこと。
これは当たり前に聞こえるが、自分は初回の試行でそのまま SIGSEGV したので、この落とし穴を明確に書いておく。
第四の教訓:再現可能性は最低条件
どんなに野心的な patch プロジェクトでも、初日から reapply スクリプトを必ず添えるべきだ。理由は:
- システムアップグレード / app 更新 / 自分の手すべりで一度上書きした — こういう場合に 30 秒以内で patched 状態へ戻る必要がある。
- 作業を二台目の Mac に複製するとき、「自分が手動でどのバイトを変えたか」だけが唯一のドキュメントだと破滅的。
- 復盤やチーム共有のとき、スクリプトは最良の「何をしたか」の叙述になる — 各行が明確な一つの動作だからだ。
スクリプト形式の推奨:
- テーブル駆動にする。hex blob ではない。各 entry は
(univ_offset, expected_old_hex, new_hex, description)。 - 各 patch は書き込む前に、元のバイトが期待通りか check する。不一致なら skip + ログ記録し、スクリプト全体を abort しない — そうすればスクリプトは idempotent で、繰り返し実行しても無害。
- patch 後に
codesign --verifyを一度呼んで、再署名された状態を確認する。 - 最後に、環境側の強化策(たとえば自動更新の無効化)も再度 reassert する。この部分は per-user defaults なので、移行時に失われやすい。
五つ目の教訓:検証は patch 自体より時間がかかる
patch ファイルを書き、署名し、プロセスを起動する — これはまだ第一歩にすぎない。本当に有効になったことを確認するには、次のステップが必要:
- 静的確認:
xxdでディスク上のバイトが確かに変わったことを見る。 - 再署名確認:
codesign -dvで signature が新しくなっていることを確認する(codesign は、ほんの数バイト変えただけだからといって文句は言わないが、再署名を忘れると文句を言う)。 - 読込確認:プロセスが立ち上がったら
vmmap <pid>で Load Address を取得し、ASLR slide を計算してから、lldb attach で実行中メモリ内の patch 後の位置を読む。元の stp ではなく movn の encoding が見えれば、patch が本当に CPU の実行経路に乗っていることになる。 - 挙動確認:patch 経路を通る UI / API を発火させ、目視またはログで挙動変化を確認する。
- 回帰確認:app を十分な時間走らせ、patch したのが一回限りの初期化経路ではなく、安定して効く箇所であることを確認する。
この五つはどれも欠かせない。何度か、step 1 + 2 は問題なさそうに見えたのに、step 3 で ASLR slide の計算が間違っていると分かったことがある — プロセス内のバイトはまだ古いままだった。理由:
- ファイル不一致:改変したのが
/Applications/...内で実際に起動されているコピーではなかった。 - 署名キャッシュ:codesign 後、macOS カーネルには cache があり、古い app インスタンスの
Code Signing Identifierが新しく署名したものと一致しなくても、カーネルがすぐには認識しない。 - ASLR slide 計算ミス:Mach-O 64-bit executable の
__TEXTvmaddr base は0x100000000(これは link 時に決まる。otool -l <bin> | grep -A4 'segname __TEXT'の vmaddr フィールドで確認する);dylib / framework の base は通常0x0。slide = LoadAddress - static_baseであり、LoadAddress 自体ではない。executable の slide 計算には0x100000000を、framework には0x0を使う — 混同しないこと。 - TCC 権限が CDHash で失効する:
codesignの再署名で binary の CDHash が変わり、macOS の TCC(Privacy & Security)は CDHash を権限の主キーとして扱う。以前付与した Accessibility / Input Monitoring / Screen Recording / Full Disk Access はすべて黙って失効する — UI 上の toggle はオンに見えるが、実際には権限がない。症状:⌥d / グローバルショートカットなどが突然反応しなくなる。修正法:System Settings → Privacy & Security → Accessibility(または対応項目)で、app を**削除してから再追加**する。toggle off/on ではない。
どれも一度は 20〜30 分を失わせてくれた。
チーム share で何を話せるか
この作業を社内 share に落とし込むなら、こういう構成を勧める:
- 「ソフトな方法 vs ハードな方法の転換点の見極め」 — これは経験であって、知識ではない。5 つの具体的なソフト方法の失敗と、それぞれの失敗が何を露呈したかを話す。新人はたいてい、ある種のソフト方法に丸一日食らいつくので、他人の失敗パターンを見るだけで時間を節約できる。
- 「ツールチェーンは並列の代替ではなく、スタック式の積み重ね」 — Ghidra のデコンパイルで大筋を見て、llvm-objdump で局所的な asm を掘り、lldb で runtime を検証する。IDA だけ、あるいは r2 だけを使う同僚を見たことがあるが、ある種の場面で詰まる。
- 「キラーパッチを探す考え方」 — callgraph ツールで入次数が最も高い関数を列挙する。静的解析と組み合わせて判断点を特定する。このパターンは防御側で「重要な保護対象」を識別するのにも同じように有効。
- 「reapply スクリプトと再現性の規律」 — これは最も過小評価されやすい。demo と組み合わせる:練習用 app を使って一連の流れを最後のスクリプトまで通し、チームメンバーにその場で「うわ patch が消えた — スクリプトを走らせる — 戻った」を体感してもらう。
- 「検証の 5 階層」 — 上の 5 ステップそれぞれに、実際に踏んだ落とし穴の事例を 1 つずつ添える。
テーマの着地点は必ずしも「どう crack するか」ではなく、「バイナリ層の観測可能性と再現性」。このスキルセットは、本番 crash の調査、セキュリティ監査、third-party SDK の sanity check にも使える。
結び
この 8 時間のうち、6 時間はソフトな方法を試すことに費やし、2 時間で最終的な 18 個の patch の 90% を作った。効率が悪く聞こえるかもしれない — でも、その 6 時間は無駄ではなかった。ソフトな方法が失敗するたびに、ターゲットの内部構造について 1 つ学びがあり、それらの情報は後にすべて、最終的な patch 箇所の判断に沈殿していった。もし最初からいきなり Ghidra で黙々とデコンパイルしていたら、事前仮説がないまま、むしろ間違った関数にもっと時間を使っていただろう。
リバースエンジニアリングの本質は、「情報密度の最適化」という仕事だ — 手元にあるバイナリは高エントロピーであり、限られた時間のなかでツールを使ってそのエントロピーを圧縮し、「どの数行のコードが、どのユーザー可視の挙動を決めているのか」という問いいに答える。この意味で、攻防演習の訓練価値は最終的な patch にあるのではなく、「制約された情報のもとで構造を推論する」筋肉記憶にある。
この筋肉記憶は、コードを書いてプロダクトを作る人たちにも役立つ。
コメント
コメントは即時公開されますが、ポリシー違反時は非表示になる場合があります。