当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで96.75%のスコアを記録しました。 詳細を見る

セキュリティ

セキュリティ

モバイルアプリシールディングの回避:検知の先で強制が破綻する場所

検知と強制は別々のセキュリティ特性です。4つの商用シールディング製品で保護された本番環境の銀行アプリ5本を調べたところ、検知は高度でしたが、強制は脆弱でした。

モバイルアプリシールディングとは、アプリケーション自体にコンパイルして組み込まれる保護機能です。RASP(実行時アプリケーション自己保護)、改ざん防止、アンチフッキング、root検知、ジェイルブレイク検知、実行時保護などの名前で販売されていますが、約束していることはいずれも同じです。root化された端末上、エミュレーター内、Fridaの制御下、デバッガーにアタッチされた状態、あるいは再パッケージ化された状態にあることにアプリが気付いたら、それを察知して対応するというものです。

この約束には2つの側面があり、それぞれが独立して破綻します。

検知とは、何かがおかしいと判断することです。強制とは、それに対して実際に何らかの措置を取ることです。

製品がrootを完璧に検知できても、アプリケーションがその答えを無視したり、すべての答えを一つの脆弱な分岐に集約したり、攻撃者が制御する入力を信頼したりすれば、何も保護できません。

当社は、AndroidとiOSで動作し、4種類の異なる商用シールディング製品を使用している本番環境の銀行アプリ5本を評価しました。検知は一貫して優れていました。破綻していたのは、一貫して強制の部分でした。

最初の症状:何も伝えないように作られたクラッシュ

スキャンでは、最初のアプリをテスト端末にインストールして起動しました。5秒後、プロセスは消えていました。ダイアログもエラーもなく、ログにも何も残っていません。

クラッシュレポートは次のとおりです。

signal 11 (SIGSEGV), fault addr 0x0
x0=0xdef040f0  x1=0x738416ae  x4=0x2319d258  x5=0xbd7d47fb
x6..x30=0      sp=0      pc=0
tid: Thread-7x

シグナル11はSIGSEGV、つまりセグメンテーション違反です。プロセスが、カーネルの拒否するかたちでメモリにアクセスしたことを意味します。ここまではよくある話です。異常なのはレジスタダンプです。

ARM64では、pcはプログラムカウンターで、実行中の命令のアドレスを指します。spはスタックポインターで、コールスタックの基点となります。x0からx30は汎用レジスタで、引数、戻り値、ローカル変数を保持します。本物のクラッシュではこれらの値が残るため、クラッシュレポートが役に立ちます。プログラムカウンターは障害を起こした命令を示し、スタックポインターを使えばデバッガーで呼び出し元をさかのぼれるからです。

ところが、ここではそのすべてがゼロになっています。調べるべき障害命令も、巻き戻すべきスタックもありません。唯一のバックトレースフレームは#00 pc 0x0 <unknown>です。

何かが、プロセスを終了させる前に意図的にレジスタファイルを消去したのです。レポートに記載されたスレッドThread-7xは汎用のワーカーで、この判定とは何の関係もありません。また、0xdef040f0はこのプラットフォームではあり得ないポインター値で、保護コードが残したマーカーのように振る舞っています。

これは、堅牢化されたアプリでよく見られるパターンです。アプリは判定結果を知らせる代わりに、終了する際にフォレンジック上のエビデンスを破棄します。そのため、解析者はどのチェックが作動したのかも、それがどこにあるのかも知ることができません。

ベースラインのクラッシュを示す評価のエビデンスページ。すべての汎用レジスタがゼロにされ、利用可能なバックトレースがない
図1:アプリが残すクラッシュ

すべての汎用レジスタがゼロで、バックトレースは不明なフレームが一つだけです。

これらの製品が実際に監視しているもの

最初の作業は棚卸しです。この仕組みは何をチェックしているのでしょうか。

静的解析では、約30のネイティブ検知関数が見つかりました。いずれも、ロード時に単一のJavaクラスに登録されています。

この点は、見た目以上に重要です。AndroidアプリはJNI(Java Native Interface)を通じてネイティブのCやC++のコードにアクセスします。ネイティブライブラリが関数をJavaに公開する方法は2つあります。一つは、Java_com_example_Foo_barのようなマングルされた名前でシンボルをエクスポートする方法で、ライブラリに対してnmを実行すれば誰でも確認できます。もう一つは、JNI_OnLoadの中でRegisterNativesを呼び出し、実行時にメソッドを動的にバインドする方法です。

このライブラリは後者の方法を使っているため、検知関数の名前はどれもエクスポートされたシンボルとして現れません。見つけるには、登録テーブルを読むか、実行時にプロセスを観察する必要があります。

JNI_OnLoad              @ 0x424264
checkHooks              @ 0x424e50     scans the process memory map
checkForFridaAgent      @ 0x424c38
isSuExists              @ 0x424940
isFoundMagisk           @ 0x425a90
isFoundDangerousProps   @ 0x424810
isPermissiveSelinux     @ 0x4248e8
getNativeSignature      @ 0x42ca00     hashes the signing certificate

ここには4つのカテゴリが含まれており、それぞれが異なる問いに答えます。

root検知は、端末が昇格した権限を与えている証拠を探します。ディスク上のsuバイナリ、Magiskの痕跡、書き込み可能なシステムパス、permissiveなSELinux、rootマネージャーのパッケージなどです。rootが重要なのは、攻撃者がアプリのプライベートストレージを読み取り、プロセスにアタッチし、その実行時の状態を改変できるようになるからです。

フッキング検知は、実行時に関数の振る舞いを書き換えるフレームワークを探します。Frida、Xposed、LSPosed、Zygisk、Substrate、SandHook、Taichi、VirtualXposedなどです。フックを使えば、ディスク上のAPKに手を加えることなく関数の戻り値を変更できます。クライアント側のセキュリティチェックが破られるのは、まさにこの方法によってです。

checkHooksは/proc/self/mapsを読み取ります。LinuxとAndroidでは、このファイルには現在のプロセスにマッピングされたすべてのメモリ領域が、その権限と元になるファイルとともに一覧表示されます。そこに外部のライブラリや匿名の実行可能領域が現れれば、アプリは自分が計装されていると推測できます。

署名検証はgetNativeSignatureが担い、実行時にアプリの署名証明書のハッシュを計算します。Androidでは、すべてのAPKに署名が必要です。アプリを改変して再パッケージ化する攻撃者は、通常は別の鍵で再署名しなければなりません。そのため、実行時の証明書を想定されるハッシュと比較すれば、単純な再パッケージ化を検知できます。

危険なプロパティの検知は、Androidのシステムプロパティを読み取ります。システムプロパティとは、OSがビルドや端末の構成を公開するために使うキーと値のペアで、ro.build.tags、ro.debuggable、ro.secure、ro.hardware、ro.product.modelなどがあります。エミュレーターのイメージやエンジニアリングビルドは、ここに見分けのつく値を残します。

この有償製品の下には、無関係なベンダーによる制御がさらに3つ存在します。無料のオープンソースライブラリによる2つ目のrootチェック、アプリ自体のコンパイル済みDartコード内にある3つ目のrootチェック、そして4つ目のベンダーによるテレメトリです。マニフェストには<queries>エントリまで宣言されており、パッケージマネージャーがsu、Magisk、KingRootのパッケージに関する問い合わせに応答するようになっています。

一つのアプリに4つの独立した制御があり、同じ端末について4つの独立した見解を持っているわけです。このことは最後に重要になります。

これは表面的な実装ではありません。弱点は検知が欠けていることではなく、アプリが何を信頼し、得られた答えをどう扱うかにあります。

チェックを読み解くアプローチが通用しない理由

解析者とこのロジックの間には3つの層があり、そのすべてがきちんと役割を果たしています。

Java層はおとりです。アプリには、本来メソッド本体があるべき場所に4,129個の暗号化されたblobが含まれており、起動時にのみ復号されて実行されます。メインアクティビティを含む重要なクラスは、ファイル内に読める形では存在しません。逆コンパイラーで分かるのは構造と一部の呼び出し箇所だけで、振る舞いは分かりません。

ネイティブライブラリはパックされています。保存時には暗号化されており、エクスポートされたシンボルは一つだけで、実際の内容は起動後にメモリ上にのみ存在します。あるライブラリは、ランダム化されたファイル名とランダム化されたエクスポート名を持っています。

libGHDSDFIUPOIFDLS8DSFN23LK.so
    export: _3Wbwdz5QepMbJNn8CiW3HwFivKZsZoNvu

そこでスキャンでは、実行中のプロセスから復号済みのライブラリをダンプし、改めて調べました。それでも、チェックはそこにありませんでした。

チェックは実行時に生成されます。復号されたコードは69回のシステムコールを発行します。ARM64でのシステムコールはsvc命令で、呼び出し番号はレジスタx8に入るため、通常は逆アセンブラーでどのカーネル関数が要求されているかを読み取れます。このうち64か所では番号が定数です。残りの5か所では、アプリが実行中に番号を計算するため、その5か所は静的解析では見えません。

その5か所のうち一つは、PROT_READ | PROT_WRITE | PROT_EXECで1ページを要求するmmapに解決されます。アプリはこれをおよそ85ミリ秒ごとに実行します。そのページにコードを書き込み、実行し、破棄するのです。

書き込み可能かつ実行可能なメモリは、設計上まれなものです。現代のシステムは2つの権限を分離しています。書き込んだうえで実行できるメモリこそが、コードインジェクションを可能にするからです。それでもパッカーやプロテクターがこれを使うのは、まさにこの目的のためです。つまり、固定されたアドレスに固定されたコードとして存在することのないチェックを生成するためです。

文字列は保存されず、組み立てられます。完全に復号したライブラリでqemu、goldfish、emulatorを検索しても、ヒットはゼロです。アプリはこれらの文字列をスタック上で1文字ずつ組み立てるため、grepで探せる連続したテキストとして存在することはありません。

メソッド名はUnicodeの紛らわしい文字です。別の製品で保護された2つ目のアプリは、Javaで同じ手口を使っています。逆コンパイラーはm13674と表示しますが、実行時の本当の名前はUnicodeの修飾文字であるˎです。m13676と表示されるものの本当の名前はΙで、これは大文字のiとまったく同じように表示されるギリシャ文字の大文字イオタです。

逆コンパイラーが表示する名前を指定して書いたフックは、まったく作動しません。エラーも失敗もなく、その沈黙を実際以上に強力な保護だと誤解しやすくなります。名前ではなく型シグネチャでメソッドを解決すればこの問題を回避でき、本記事で後ほど紹介するフックが初回で成功しているのはそのためです。

外部からアプリを計測する

コードが読めないように作られているなら、読むのをやめて、代わりに観察します。

スキャンでは、プロセスの外部から観察し、プロセス内部に検知される痕跡を何も残さないカーネルレベルのトレースをアタッチしました。そのうえで、起動中にアプリが開いたすべてのファイルを記録しました。

opens ~150 property files, three times each
reads /proc/self/maps three times
reads /proc/self/status, /proc/self/comm, /sys/fs/selinux/context
opens the virtual device files: 0 times
opens any su or root path: 0 times

/procに馴染みのない読者のために説明すると、これはカーネルが必要に応じて生成する仮想ファイルシステムで、/proc/selfは呼び出し元プロセス自身から見た自分自身の情報です。mapsはマッピングされたメモリ領域を一覧表示します。statusはプロセスのメタデータを保持しており、その中には、現在このプロセスをトレースしているもののプロセスIDであるTracerPidが含まれます。commはプロセス名です。/sys/fs/selinux/contextは、プロセスが動作しているSELinuxのセキュリティコンテキストを報告します。

この結果によって、すべての見方が変わりました。この種のチェックは、ディスク上のsuやエミュレーターのデバイスノードを探すものだと、ほとんどの人が考えています。しかし、このチェックはそのどちらも一度も開いていません。起動時の判定は、プロパティの値、プロセス自身のメモリマップ、そしてトレースの状態に基づいています。この3つのうち2つは、特権ユーザーが書き換えることができます。

静的解析では、重要かもしれないチェックの一覧が得られました。トレースによって、実際に実行されるのがどれかが分かりました。

プロパティによるゲートはファイルの書き込みで崩れる

Androidは、/dev/__properties__/配下のファイルとして公開される共有メモリ領域を通じて、システムプロパティを公開しています。ライブラリは__system_property_getを通じてそれらを読み取ります。値はメモリ上に保持されており、root化された端末では、アプリの起動前にその場で書き換えることができます。このゲートは、そこで読み取る値について、完全性や真正性のチェックを一切行いません。

書き込み前:

signal 11 (SIGSEGV), fault addr 0x0
x0=0xdef040f0, pc=0, sp=0
crash report: GENERATED

書き込み後:

no crash report
process alive
UI drawn
native libraries loaded

プロパティ書き込み後の同じエビデンスページ。クラッシュレポートはなく、プロセスは稼働中で、ネイティブライブラリが読み込まれている
図2:プロパティ書き込み後

強制終了は起きなくなりました。APKへのパッチも、フックも、アプリへの改変も一切ありません。

だからといって、プロパティのチェックが無意味になるわけではありません。それが決定的な根拠にはならない、ということです。rootを取得した攻撃者が値を書き換えられるのであれば、アプリには、独立した複数のシグナルによる裏付けか、一つのシグナルが反転しても崩れない強制の仕組みが必要です。

インデックスファイルが重要な理由

これは見た目以上に繊細な作業であり、その失敗パターンは知っておく価値があります。

そのディレクトリには、2種類の異なる構造が格納されています。各プロパティを保存するprop_infoレコードと、プロパティの名前をそのレコードに解決するための、別個のシリアライズされたインデックスです。名前による検索はすべてインデックスを経由します。ファイル全体に対してやみくもに検索と置換を行うと、インデックスが壊れてしまいます。

しかも、その損傷は目に見えません。プロパティの列挙はレコードを直接たどるため正常に動作し続け、約460件のエントリを返します。一方で、個々のプロパティを名前で問い合わせると何も返ってきません。起動時にプロパティを読み取るライブラリはすべて、直前に行った操作とは無関係に見えるかたちで動かなくなります。

そこでスキャンでは、書き込む前に形状から本物のレコードを特定し、さらに自らの作業を信頼するのではなく検証します。

properties : 465  (was 465)
sdk        : '31'
giveaways  : 0

プロパティ数は変わらず、既知の検索は引き続き解決され、痕跡は消えています。アプリを起動するのはその後です。環境を壊すことで成立する回避は、回避とは言えません。

Frida検知の実態はタイミングの検知

Fridaは、この種の作業における標準的な動的計装ツールキットです。実行中のプロセスにエージェントを注入し、Javaメソッド、ネイティブ関数、システムコールをフックできます。

これらのアプリに対しては、Fridaは即座に強制終了されます。一方、Fridaよりも侵襲的だと言えるデバッガーは、いつまでも動かし続けることができます。

debugger attaches 1.9s after launch   ->  ran 120 seconds, no complaint
Frida attaches 1.0s after launch      ->  killed
Frida launches together with the app  ->  killed

アプリは起動直後に一度だけ/proc/self/statusを読み取ります。このファイルの1行がTracerPidで、何もプロセスをトレースしていなければ0、そうでなければトレーサーのプロセスIDを保持します。デバッガーはptraceを使用するため、この値が設定されます。Fridaの注入も、スレッドを乗っ取ってエージェントを読み込ませるために、短時間だけptraceを使用します。

このチェックは、起動からおよそ1秒の時点で一度だけ実行されます。当社はこれを地道な方法で確認しました。デバッガーをアタッチして再開し、その後100秒間何もしないというものです。判定は下されず、アプリは稼働し続けました。監視されていることがトリガーではないのです。

Fridaの問題は、その侵入の仕方にあります。注入は、その起動時の時間枠に2つの痕跡を残します。数百ミリ秒の間TracerPidが設定されることと、エージェントが匿名ファイルから読み込まれた箇所に、外部の実行可能なマッピングが/proc/self/mapsに現れることです。判定は、エージェントのスクリプトが最初の1行を出力する前に下されます。

{"c":7,"v":1,"p":{"id":107,"name":"Hooking Detected"}}

これを回避するために、Fridaのスレッド名やポートを変更しようとする人がいます。しかし、それでは何も変わりません。検知されていたのは、そもそも名前ではないからです。

つまり、アプリが問うているのは「これはFridaか」ではありません。「この時間枠の間に、何かが自分をトレースしたり改変したりしていたか」に近い問いです。これは、異なる帰結を持つ別種の制御です。チェックが既知のタイミングで一度だけ実行されるなら、そのタイミング自体がアタックサーフェスになります。チェックの前に入り込むことも、後に入り込むことも、プロセスをサスペンド状態で起動し、アプリが最初の命令を実行する前に待ち構えておくこともできます。スキャンは最後の方法を取っており、それによって、通常のアタッチでは決して見えないチェックにまで到達しています。

2つ目のアプリは、別の経路で同じ結論に至ります。最初の命令からトレースすると、判定のすべては、アプリが最後に行う4つの処理に集約されています。

openat(".../split_config.arm64_v8a.apk")
readlinkat("/proc/self/fd/60", ".../split_config.arm64_v8a.apk")
openat("/proc/self/status")
--- SIGBUS {si_code=BUS_ADRALN, si_addr=0x8f19c0bf} ---
+++ killed by SIGBUS +++

BUS_ADRALNを伴うSIGBUSはアラインメント違反で、CPUがそのアクセス幅では受け付けないアドレスに対して読み込みや書き込みが行われたことを示します。ARM64では偶発的に起きることの少ないクラッシュであり、しかも障害アドレスは毎回同じです。アプリはトレースの状態を読み取り、その後、意図的にアラインメントのずれたアクセスを実行して自らを終了させているのです。

1つの整数が8か所を決める

2つ目のAndroidアプリは、今回調べたアプリの中で最も明確に核心を示しています。

その保護は本格的なものです。検知用の文字列はネイティブライブラリ内で暗号化され、クラス名はJava層で暗号化され、メソッド名はUnicodeの紛らわしい文字で、システムコールはlibcを経由せずインラインのsvc命令として発行されます。そのため、標準のsyscall()ラッパーをフックしても何も捕捉できません。

そのすべてが、一つのJavaメソッドに集約されています。このメソッドにランダムな整数を渡すと、クリーンな端末では同じ整数がそのまま返ってきます。

アプリ内のすべての強制箇所が、その一つの値をチェックしています。

j/ma.java:480    if (rd.m13674(ctx, nextInt) != nextInt)
j/ma.java:551    if (rd.m13674(ctx, nextInt2) != nextInt2)
bk/a.java:844    m9379 = rd.m13674(ctx, nextInt) == nextInt ? -91 : 808;
bk/a.java:911    if (rd.m13674(ctx, nextInt2) == nextInt2)
ca/ma.java:2298  if (rd.m13674(ctx, r0) == r0)
ca/b.java:1331   if (rd.m13674(ctx, r0) == r0)
ca/a.java:816    if (rd.m13674(ctx, r0) == r0)
y/b.java:192,228 rd.m13674(ctx, SecureRandom.nextInt())

ゲートの連鎖と、すべてが一つの戻り値を読み取る8か所の強制呼び出し箇所を示す検出結果
図3:1つのメソッド、8か所の呼び出し箇所

ランダムな整数は飾りではありません。これはナンスであり、最も手抜きな攻撃を防ぐために存在します。もしメソッドがクリーンな端末で単にtrueや0を返すだけなら、攻撃者はそれをフックして常にその定数を返させるでしょう。呼び出しのたびに新しいランダムな値を渡し、それが返ってくることを要求すれば、期待される答えが毎回変わるため、定数を返すだけでは失敗します。

しかし、ここではそれが役に立ちません。メソッドをフックできる攻撃者であれば、渡された引数をそのまま返せばよく、そうすればすべての箇所で比較が成立してしまいます。

名前がUnicodeであるため、名前ではなく型シグネチャで解決した3つのフックは次のとおりです。

{"type":"hooked","method":"m13674","sig":"int(android.content.Context,int)"}
{"type":"hooked","method":"m13676","sig":"boolean(java.lang.Exception)"}
{"type":"hooked","method":"m13677","sig":"int(android.content.Context,int,int)"}
{"type":"alive","pid":6285,"ping":1} ... {"type":"alive","pid":6285,"ping":18}

評価の動的エビデンス:端末の割り当て、サスペンド状態での起動、解決された3つのフック、継続的な実行
図4:再現された回避

2分間の実行で、プロセスは安定して動作し、クラッシュもなく、改ざん検知の経路は一度も作動しません。

この失敗は、暗号の問題ではなくアーキテクチャの問題です。ネイティブ層は正しい判定を算出していました。ところがアプリケーションは、その判定を、プログラムの中で最も保護の弱い部分に置かれた、変更可能な一つの整数として信頼していたのです。

ピンニング回避を正しく実証する方法

証明書ピンニングとは、アプリがOSのトラストストアだけに依存しないことを意味します。サーバーの証明書、またはその中の公開鍵が、アプリに同梱された値と一致するかどうかもチェックします。適切に実装されていれば、攻撃者が端末に独自のルートCAをインストールしても防御が維持されます。

しかし、ピンニングはアプリケーションのコードであるため、比較処理をフックしたりパッチを当てたりすれば無効化できます。

多くのレポートは、これを弱い根拠で実証しています。フックが読み込まれた、プロキシにトラフィックが表示された、アプリがクラッシュしなかった、といったものです。これらは回避と矛盾しませんが、ほかのいくつかの説明とも矛盾しません。

スキャンでは代わりに、アプリ自身の証明書チェッカーに対して対照実験を行いました。チェッカーはアプリ自身のコンストラクターで生成し、アプリ自身のピンリストを読み込ませています。2回とも同じ証明書、同じオブジェクト、同じピンを使用しました。唯一の変数は、フックが有効かどうかだけです。

同じ証明書、同じチェッカー、同じピン 回避なし 回避あり
ピンの比較(1つ目のピン) false true
ピンの比較(2つ目のピン) false true
証明書の検証 拒否され、例外がスローされた 受け入れられ、エラーなし

ほかのすべてを固定したまま、一方の実行では拒否され、もう一方では受け入れられています。目指すべき基準はこれです。「ツールが成功したと言った」ではなく、「管理された条件下で、保護された判定が変化した」ことです。しかもこのテストでは、アプリが接続を行うのを待つのではなくピンニングのコードを直接動かすため、プロキシもキャプチャしたトラフィックも必要ありませんでした。

iOSでは、誰もレシートを確認しない

iOSアプリには、ジェイルブレイクのゲート、2つの異なる言語で書かれた2つの独立したピンニング実装、35 MBの保護フレームワーク、そして複数のサードパーティSDKが含まれています。

それでも、自身のコードが改変されたかどうかは一度もチェックしていません。

iOSにおける自己検証とは、SecStaticCodeCheckValidityのようなAPIやcsopsシステムコールを通じて自身のコード署名についてシステムに問い合わせること、あるいは自身の__TEXTページのハッシュを計算して想定値と比較することを指します。スキャンでは、保護に関連する7つのバイナリすべてについて、こうした標準的な形態をすべて検索しました。

main binary            36.8 MB    self-check: NONE
Flutter framework      19.6 MB    self-check: NONE
protection framework   35.0 MB    self-check: NONE
attribution SDK         0.6 MB    self-check: NONE
jailbreak detection     0.07 MB   self-check: NONE
fingerprinting SDK      0.9 MB    self-check: NONE
monitoring agent        5.9 MB    self-check: NONE

難読化されたバイナリでは、シンボルが欠けていてもほとんど何も証明できません。シンボルは削除できますし、呼び出しは生のシステムコール番号で行えるからです。そのため、スキャンはそこで止まりませんでした。保護フレームワークを逆アセンブルし、そのフレームワークがシステムコールを直接発行している1,761か所すべてを確認しました。そのどれも、コード署名を照会していませんでした。

このフレームワークは、稼働中のデバッガーや稼働中の注入を見つけることはできます。しかし、自身のバイトが変更されたことには気付けません。

そこで、実際にバイトを変更しました。それぞれ数命令からなる3つのパッチです。

Jailbreak check
  sub sp, sp, #0x40        ->   mov w0, #0 ; ret

Flutter TLS pinning
  ldrb w0, [sp, #8]        ->   mov w0, #1
  bic w8, w0, w0, asr#31   ->   mov w8, #1

Payment SDK pinning, four places
  mov w1, #2               ->   mov w1, #1

ARM64の呼び出し規約では、w0は、関数が戻り値を格納するレジスタであるx0の下位32ビットです。mov w0, #0に続くretは、ゼロを返す完結した関数であり、Objective-CではこれはNOを意味します。したがって、ジェイルブレイクチェックのプロローグを置き換えた8バイトによって、関数全体が「ジェイルブレイクされていない」を返すものになり、その後の処理は一切実行されません。

ピンニングのパッチも、一段下のレベルで同じように機能します。信頼の判定を報告するコールバックに、成功を報告させるのです。

興味深いのは3つ目のパッチです。アプリに不正な証明書を受け入れさせるのではなく、アプリが証明書を拒否する4か所を変更しました。これらの箇所では、接続のキャンセルを意味する2をチャレンジハンドラーに渡していました。現在は、システムの通常の評価にフォールバックすることを意味する1を渡しています。正当な証明書を実際に受け入れる唯一の箇所には手を加えていません。

アプリは受け入れるよう指示されることなく拒否しなくなるため、その振る舞いは明らかに壊れたものではなく、もっともらしいものに保たれます。

3つのパッチはいずれも再パッケージ化と再署名を経ても残り、バンドル内の何からも反応はありませんでした。自身のレシートを一度も確認しないアプリであれば、まさに予想どおりの結果です。

シールディングは認可ではない

調査対象のうち1本のアプリには、報告に値するシールディングの欠陥はありませんでしたが、それとは関係なくクリティカルな脆弱性がありました。

Activityは、Androidアプリのエントリーポイントで、おおよそ1つの画面に相当します。デフォルトでは、アプリ内でのみ使用できます。exported="true"を指定すると、ほかのアプリから起動できるようになります。さらにインテントフィルターにBROWSABLEカテゴリを追加すると、アプリのカスタムスキームを使ったリンクをたどることで、Webブラウザーからも起動できるようになります。

このアクティビティはエクスポートされ、ブラウザーから起動可能で、トランザクションのリポジトリに書き込む前に、認証、セッション、呼び出し元のチェックを一切行っていませんでした。

アプリのデータをすべて消去し、誰もログインしていない状態で、JavaScript1行だけで十分でした。

<script>
window.location.href = "app://quickpay?payee=BrowserAttacker&amount=200.00";
</script>

ブラウザーがその画面を起動しました。その後のトランザクション一覧には、ベースラインでは5件だったエントリーが6件あり、攻撃者の受取人と金額が記録されていました。

端末には何も不審な点がなかったため、どのシールディング製品もこれを検知できなかったはずです。シールディングは、侵害された端末を攻撃するコストを引き上げます。しかし、クリーンな端末における認可の欠如には何の効果もなく、トランザクション自体に対するサーバー側のチェックの代わりにもなりません。

すべてのアプリに共通するパターン

検知技術は、どのアプリでも決して単純なものではありませんでした。パックされたネイティブコード、1秒間に何度も新しいメモリに生成されるコード、libcより下のレイヤーで発行されるシステムコール、テキストとして存在しない文字列、動的なJNI登録、メモリマップのスキャン、署名のハッシュ計算、ラテン文字になりすましたギリシャ文字のメソッド名、そして何も教えないように作り込まれたクラッシュです。

失敗はすべて、その一段内側の層にありました。

  • 特権を持つ攻撃者が書き換え可能なプロパティ値を信頼していた
  • 起動時の一つの時間枠ですべてが決まっていた
  • 8か所の強制箇所が、変更可能な一つの整数を読み取っていた
  • iOSのコードがパッチされても、それに気付く自己検証がなかった
  • あるアプリはrootを正しく検知していたが、何も強制していなかった

そして最も鋭い検出結果は、そもそも回避ではありませんでした。これらのアプリの一つを、パッチもフックもプロパティの編集もない、ごく普通のroot化された端末にインストールしました。アプリはそのままログイン画面に進み、その状態のままでした。シールディングはrootに気付き、それを正しく報告していました。それでもアプリが動作し続けたのは、その報告に耳を傾けるものが何もなかったからです。

検知器が答えるのは「何が見えているか」です。制御が答えるのは「何をするのか、そして攻撃者はその判断を変えられるのか」です。多くの導入事例は、前者には強く、後者には弱いのが実情です。

自社アプリで実施すべき3つのテスト

検知ではなく、対応を確認する。アプリがrootを検知できるかどうかを問うのではありません。root化された端末にインストールして観察してください。アプリは終了するでしょうか。機密性の高いフローを制限するでしょうか。認証をブロックするでしょうか。それとも、ログを記録して動作を続けるだけでしょうか。動作を続けるのであれば、購入したのは制御ではなくテレメトリです。

強制箇所の数を数える。判定がどのように振る舞いに反映されるかを追跡します。すべてを決める一つのメソッドはあるでしょうか。答え全体を担う一つのbooleanや整数はあるでしょうか。それはフックで変更できるでしょうか。強制は機密性の高い操作の直前で行われていますか。それとも起動時に一度だけでしょうか。

特にiOSでは、自身のコードの完全性を検証する。アプリは自身のコード署名を照会したり、自身のテキストページのハッシュを計算したりしているでしょうか。再パッケージ化や再署名を検知できるでしょうか。保護フレームワーク自体を検証しているでしょうか。フェイルクローズになっているでしょうか。それがなければ、ほかのあらゆる保護は、反転されるのを待つ一つの命令にすぎず、バンドル内の何もそれに気付きません。

評価の実施方法

モバイルシールディングは誤って評価しやすいものです。ツールが読み込まれたことは実証になりません。クラッシュが消えたことも、常に実証になるとは限りません。プロキシにトラフィックが表示されたことは、ピンニング回避の実証にはなりません。結果と同じくらい、手法が重要です。

回避の前にベースラインを取る。すべてのアプリは、まず何もアタッチしていない、root化されていないクリーンな端末で起動しました。動作するか。終了するか。終了するなら、最初に作動するのは何か。その答えが最も外側のシールドを特定し、以降のあらゆる主張の基準となります。これがなければ、保護とバグを区別できません。

両方の層をマッピングする。パッケージの検査、逆コンパイル、逆アセンブルによって、前述の検知関数の一覧とオフセット、8か所の強制箇所とその背後にあるメソッド、iOSのパッチ箇所、そして証明書検証のロジックを特定しました。

問いに合わせて端末の状態を選ぶ。ベースラインにはクリーンな端末、rootとプロパティの作業にはroot化された端末、ピンニングにはトラフィックを傍受できる端末、通常のアタッチでは見えないうちに作動するチェックにはサスペンド状態での起動、iOSの改ざんテストにはパッチを当てて再署名したビルドを使います。一つの端末状態で、すべての問いに答えることはできません。

一度に変更する変数は一つだけにする。プロパティ書き込みの前後で同じアプリを使う。ピンニングのフックの前後で同じ証明書を使う。同じプロセスで早いアタッチと遅いアタッチを比較する。パッチ適用の前後で同じiOSバイナリを使う。

観測可能なセキュリティ上の効果を求める。検出結果として数えたのは、セキュリティ上意味のある結果が変化した場合だけです。以前は終了していたアプリが稼働し続けた、UIが保護された状態に到達した、証明書の判定が反転した、リポジトリが認証されていない入力を受け入れた、パッチを当てたビルドが検知されずに動作した、といった場合です。経過時間が成功の基準になることは決してありません。「30秒間生き残った」は期待であって、観測ではないからです。

評価の分析ビュー。3段階のライフサイクルと、検証基準およびその結果の表を示している
図5:自らのループを閉じる評価

評価が自らに課したすべての基準と、それが実際に返した結果です。

よくある質問

モバイルアプリシールディングとは何か

リバースエンジニアリング、改ざん、デバッグ、フッキング、そして敵対的な環境での実行を困難にするために、アプリケーションに組み込まれる保護機能です。通常は、root/ジェイルブレイク検知、エミュレーターとデバッガーの検知、フック検知、難読化、パッキング、証明書ピンニング、改ざん防止のロジックがまとめて提供されます。

RASPとは何か

Runtime Application Self-Protection(実行時アプリケーション自己保護)の略です。モバイルでは、アプリが自身の環境を監視し、何かがおかしいと見えたときに対応することを意味します。重要なのは「対応する」という言葉です。対応を伴わない検知は、単なるテレメトリです。

検知と強制の違いは何か

検知は、環境が敵対的であると判断することです。強制は、それに対して何をするかを決めることです。アプリは、答えを無視したり、攻撃者が変更できる単一の値として答えを公開したりすることで、完璧に検知しながら何も保護しないということがあり得ます。

デバッガーは見逃されるのに、Fridaが検知されるのはなぜか

Fridaが何をするかではなく、どのように入り込むかが理由です。注入は一時的にTracerPidを設定し、プロセスのメモリマップに外部の実行可能なマッピングを残します。起動時にこの2つをサンプリングするアプリは、スクリプトが実行される前に注入を捕捉します。そのチェックの後にアタッチするデバッガーは、どちらの痕跡も残しません。

1つのフックで8つの保護が同時に破られたのはなぜか

8か所の強制箇所すべてが、一つのメソッドの戻り値を読み取っていたからです。判定を一元化すると、組み込みが容易で監査も簡単になりますが、それは一つのフックですべてを無効化できることも意味します。

root検知だけで十分か

十分ではありません。root検知は入力であって、制御ではありません。アプリは依然としてそれをどう扱うかを決める必要があり、rootの痕跡を隠したり、プロパティを書き換えたり、検知器をフックしたり、アプリにパッチを当てたりできる攻撃者も考慮しなければなりません。

証明書ピンニングだけで十分か

十分ではありません。ピンニングはアプリケーションのコードで実装されているため、そのコードをフックしたりパッチを当てたりすれば無効化できます。導入する価値はありますが、機密性の高いトランザクションにおける唯一の保護にすべきではありません。

シールディングでビジネスロジックの欠陥を防げるか

防げません。シールディングは敵対的な実行環境を検知することはあっても、認証、認可、サーバー側の検証、トランザクションの完全性の代わりにはなりません。認証されていないディープリンクでトランザクションを作成できるのであれば、シールディングは適切な防御策ではありません。

モバイルチームが最初にチェックすべきことは何か

アプリをroot化された端末にインストールし、停止するかどうかを確認してください。次に、判定を読み取っている箇所がいくつあるかを数えてください。そしてiOSでは、バイナリが自身の署名を検証していることを確認してください。この3つの答えによって、それ以外への投資が実際に効果を上げているかどうかが決まります。

参考文献