はじめに
ジェイルブレイク環境では、音楽を再生しながらリスプリング(SpringBoardの再起動)を行う場面が頻繁にあります。Tweakのインストール、設定変更の反映、見た目のリフレッシュ──理由は様々ですが、その裏で「意図しない音声の停止」というユーザー体験の悪化が長年放置されてきました。
本記事では、実機(iPhone 8 Plus, iOS 16.7.16, rootful環境)での検証を通じて発見した、iOSの電源・リスプリング関連の内部挙動と、それらを安全に制御する具体的な実装方法を紹介します。
発端:なぜ「Killしただけ」で音楽が止まるのか
素朴な仮説は「killall backboarddのようなコマンドを実行すると、backboardd というプロセスが再起動され、それに伴って他のアプリの表示が一瞬止まるだけだろう」というものでした。ところが実機で検証すると、状況はもっと深刻でした。
発見1:backboardd は「表示が切れる」のではなく「プロセスごと強制終了される」
Fridaを使い、Music.appとSpringBoardの双方に同時にトレースを仕掛けた状態でkillall backboarddを実行したところ、次のことが分かりました。
- Music.appは
AVAudioSessionInterruptionなどの通知を一切受け取らないまま、プロセスごと強制終了される - SpringBoard自身も同時に再起動される(PIDが変わる)
- つまり
killall backboarddは、backboardd単体の再起動ではなく、実質的にフルリスプリングと同等のカスケードを引き起こす
さらに踏み込んで分かったのは、backboardd は外部からの干渉そのものを許容しないという事実です。Fridaでプロセスに接続を試みる(何も変更を加えない、ただの観測目的の接続)だけで、backboardd自体がクラッシュしました。これは偶然の実験結果として得られたものですが、「backboardd Killは原理的に対処不能」という結論を裏付ける強力な証拠になりました。
対策:kill(2)を「握りつぶして」安全な代替に置き換える
ここで採用したアプローチは、backboardd宛のシグナルをそもそも配送しないというものです。
%hookf(int, kill, pid_t pid, int sig) { if (pid > 0 && RKAIsAuthorized()) { char name[64] = {0}; proc_name_fn pnFn = RKAProcName(); int len = pnFn ? pnFn(pid, name, sizeof(name)) : 0; if (len > 0 && strcmp(name, "backboardd") == 0) { RKAMarkBackboarddKilled(); if (RKARedirectBackboarddKillSignalEnabled()) { CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), kGentleRespringRequestName, NULL, NULL, true); return 0; // %origを一切呼ばない = backboarddには何も届かない } } // ... } return %orig(pid, sig); }
kill()はlibSystemの正規の公開シンボルであるため、Logosの%hookfで素直にフックできます。ポイントは「シグナルの種類をダウングレードする」のではなく、「そもそも配送しない」ことです。以前試した「SIGKILLをSIGTERMに格下げする」方式では、backboardd はSIGTERMを受け取っても同じようにクラッシュしました。これは、backboardd の脆弱性がシグナルの種類に依存しない、より根本的な性質であることを示しています。
その代わりに、SpringBoard自身が持つ正規のAPI(SBSRelaunchAction経由のFBSSystemService sendActions:)を使って、安全なジェントルリスプリングを要求します。呼び出し元の意図(「UIをリフレッシュしたい」)は尊重しつつ、実際の実装だけを安全なものに差し替える、というのがこの設計の核心です。
発見2:呼び出し元によって注入が届かない場合がある
このkill()フックは、コマンドラインからkillall backboarddを実行した場合や、Zeus(別のリスプリングツール)から実行した場合には正しく機能しました。しかし、サンドボックス化されたアプリ(Preferences.app)内からNSTaskでkillallを起動した場合、Tweakの注入自体が一切届かないという制約が判明しました。
// 修正前:注入が届かないサンドボックス経由のkillall - (void)respring { NSTask *task = [[NSTask alloc] init]; [task setLaunchPath:@"/usr/bin/killall"]; [task setArguments:@[@"backboardd"]]; [task launch]; }
これはMobileSubstrate/ElleKit側の注入メカニズムの限界であり、kill()フックのロジックをいくら改良しても解決できません。唯一の解決策は、そもそもbackboarddに触れないように呼び出し元自体を書き換えることでした。
// 修正後:安全なDarwin通知を直接送る - (void)respring { CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), CFSTR("com.mikiyan1978.playonrespring.gentleRespring"), NULL, NULL, true); }
Darwin通知の送信にはエンティトルメントが不要なため、サンドボックスの有無に関わらずどのプロセスからでも安全に呼び出せます。これは、Apple自身が用意した正規の低レベルIPC機構を使うことで、注入ベースの介入が届かない場所でも同じ効果を実現する、という設計パターンです。
発見3:Userspace Rebootの正体は「生のカーネルsyscall」だった
次に取り組んだのは、launchctl reboot userspace(カーネルを再起動せず、ユーザー空間のプロセスツリーだけを再構築する高速な再起動)でした。
最初の仮説は、シンボルテーブルに見つかったlaunch_userspace_reboot_with_purposeという専用関数がこの機能を担っているというものでした。しかし、launchctlバイナリを実機で実行せず、Macにダウンロードしてオフラインで逆アセンブルした結果、これは完全に誤りだと判明しました。
_reboot_cmd: ; "system" / "halt" / "userspace" / "obliterate" の文字列比較 ; それぞれ異なるフラグビットをx0にセットして... bl _reboot3 ; ← 全パターンが最終的にここに収束する
launch_userspace_reboot_with_purposeは、実は無関係なenter-rem(Restricted Execution Mode)コマンド専用の関数でした。reboot userspaceを含むすべてのlaunchctl rebootサブコマンドは、種類ごとに異なるフラグビットを立てて、生のカーネルsyscallラッパーであるreboot3(uint64_t flags)を呼んでいるだけだったのです。
| サブコマンド | フラグビット |
|---|---|
system |
0x8000000000000000 |
userspace |
0x2000000000000000 |
halt / obliterate |
複合ビット |
この発見は、実機を一切操作せずotool -tVによるバイナリ解析だけで得られたものです。ライブ環境での試行錯誤には常に「本当に再起動してしまう」リスクが伴うため、まず安全なオフライン解析で仮説を固めてから実機検証に移る、という手順を徹底しました。
reboot3のフック:%hookfが使えない特殊なケース
reboot3はlibSystemの正規のエクスポートシンボルとして存在が確認できたものの(libSystem.B.tbdに記載あり)、実際に%hookfでコンパイルを試みると、リンカが解決を拒否しました。
ld: Undefined symbols:
reboot3(unsigned long long), referenced from:
_logosLocalInit() in Tweak.xm.o
これは、シンボルテーブル上の存在と、静的リンカによる解決可否が必ずしも一致しないという、興味深いケースでした。解決策は、dlsymによる実行時解決と、MSHookFunctionによる手動フックの組み合わせです。
typedef int (*RKAReboot3Fn)(uint64_t flags); static RKAReboot3Fn RKAReboot3Symbol(void) { static RKAReboot3Fn fn; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ fn = (RKAReboot3Fn)dlsym(RTLD_DEFAULT, "reboot3"); }); return fn; } static int (*RKAOrigReboot3)(uint64_t flags); static const uint64_t kRKAUserspaceRebootFlag = 0x2000000000000000ULL; static int RKAReplacementReboot3(uint64_t flags) { BOOL isUserspaceReboot = (flags & kRKAUserspaceRebootFlag) != 0; if (isUserspaceReboot && RKARedirectUserspaceRebootEnabled()) { CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), kGentleRespringRequestName, NULL, NULL, true); return 0; // 実際のsyscallは一切発行されない } return RKAOrigReboot3(flags); } static void RKAInstallReboot3Observer(void) { RKAReboot3Fn realReboot3 = RKAReboot3Symbol(); if (realReboot3) { MSHookFunction((void *)realReboot3, (void *)RKAReplacementReboot3, (void **)&RKAOrigReboot3); } }
実機での検証結果は明確でした。フックを有効にした状態でlaunchctl reboot userspaceを実行したところ、SSH接続は切断されず、uptimeも連続したまま、backboardd のPIDも一切変わりませんでした。ログには次の一行だけが記録されます。
reboot3(flags=0x2000000000000000) called by launchctl -- swallowed (userspace reboot redirect enabled), requesting gentle SpringBoard respring instead
本来のuserspace rebootは一度も発生せず、代わりに安全なSpringBoard単体のリスプリングに完全に置き換わりました。
設計思想:「握りつぶすべきもの」と「握りつぶすべきでないもの」
ここで重要なのは、backboardd Killとuserspace rebootでは、デフォルトの挙動をあえて変えている点です。
backboardd Kill → デフォルトON(常に握りつぶす) backboardd の死は、呼び出し元の意図(大抵は「UIをリフレッシュしたい」)と実際の結果(アプリの強制終了)が常に乖離しています。巻き添え被害でしかないため、無条件に無害化して問題ありません。
Userspace Reboot → デフォルトOFF(オプトイン) 一方でuserspace rebootは、呼び出し元が「本当に全プロセスを再起動したい」という明確な意図を持って呼んでいるケースがほとんどです。特定のTweakをインストールした直後など、実際にuserspace rebootが必要な場面も存在します。ここを無条件に握りつぶしてしまうと、今度は「意図した動作が起きない」という別の不具合を生んでしまいます。
同じ「危険な電源操作を無害化する」という技術でも、その操作が持つ本来の意図を尊重するかどうかで、デフォルトの挙動を使い分けることが設計上重要でした。
検証プロセスにおける失敗と教訓
この調査の過程では、意図しない実機再起動を2度経験しました。
launchctl rebootをサブコマンドなしで実行しただけで、実際に再起動が発生- Frida の spawn+hook(プロセスをサスペンド状態で起動し、実行前にフックを仕込む手法)で、フックの設置に失敗した際、Fridaが自動的に元のプロセスを再開してしまい、本来のコマンドがそのまま実行された
この経験から得られた教訓は、「危険な可能性のあるコマンドは、まず絶対に実行を伴わない方法で調査する」という原則です。具体的には次の順序を徹底しました。
- バイナリをローカルにダウンロードし、
otool/nm/stringsでオフライン解析する - 動的解決(
dlsym)の可否を、呼び出さずに確認だけするスクリプトで検証する - 実機での動作確認が避けられない場合は、まず「観測のみで一切ブロックしない」バージョンで安全性を確認してから、実際の介入ロジックを追加する
この段階的なアプローチにより、最終的な実装(backboardd Killの完全な握りつぶしと、userspace rebootのオプトイン制御)を、事故なく実機で確認するところまでたどり着くことができました。
まとめ
- backboardd のKillは、シグナルの種類に関わらずプロセス全体をクラッシュさせ、フルリスプリング相当のカスケードを引き起こす
- サンドボックス化されたアプリからの注入は届かないため、呼び出し元自体を安全なAPI呼び出しに書き換える必要がある場合がある
- Userspace Rebootは専用APIではなく、生のカーネルsyscall
reboot3()をフラグビットで種別分岐しているだけであり、kill()と同様にフック可能 - ただし
reboot3は静的リンクでは解決できず、dlsym+MSHookFunctionによる手動フックが必要 - 「巻き添え被害を無害化する」機能と「意図的な操作を無害化する」機能では、デフォルトの挙動を分けるべきである
- 危険な操作の調査は、まず実行を伴わない静的解析から始めるべきである