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

エンジニアリング

エンジニアリング

Swiftを顕微鏡で見る:実践的な動的計装

Swiftの動的計装に関する記事です。名前マングリング、Swift ABI、Swiftにおける関数引数の抽出を取り上げ、Swiftベースのアプリケーションの動的解析を行う手順を解説します。

はじめに

セキュリティの専門家は、実行時のプログラムの挙動を深く調べて、セキュリティ上の欠陥や悪意ある活動を特定できます。iOSアプリケーションであっても、セキュリティ研究者はシステムコールを傍受して調べたり、メモリ割り当てを追跡したり、実行中のプロセスにカスタムコードを注入したりできます。こうして隠れた脆弱性を明らかにし、新たな脅威をすばやく(swiftlyという言葉遊びです)特定して無力化できます。

動的計装とは

実行前のコードを調べる従来の静的解析手法とは対照的に、動的計装はリアルタイムで機能します。実行中のプログラムと接続を確立し、その挙動を監視、調整、拡張します。

研究者は、プローブやフックとも呼ばれる専用の計装コードを、操作の開始時点で対象アプリケーションのバイナリやバイトコードに慎重に挿入することで、システムコール、メモリアクセス、ネットワークとのやり取りといった重要なイベントを監視できます。

これらのフックは、あらかじめ定めた状況に応じてデータを出力したりアクションを発火させたりすることで、計装されたプログラムの実行中に、リアルタイムの監視、プロファイリング、デバッグ、セキュリティ解析を可能にします。コードに挿入するブレークポイントのようなものと考えてください。コードがブレークポイントに到達すると、コールバックが起動されます。最後のステップは、収集したデータを解析して、アプリケーションの実行時の挙動に関する知見を得ることです。なお、この解析はセキュリティ解析に限らず、目的に応じてデバッグやプロファイリングを含むこともあります。

さっそく始めよう

プロセス全体の流れは、簡単に次のように説明できます。 1. 対象のアプリケーションを見つける 2. 監視ポイント、つまりフックを特定する 3. 任意の計装フレームワークを使って計装コードを書く 4. 計装コードを注入する 5. 最後に、結果とスタックトレースを解析する

フックの特定

アプリケーションには何千もの関数があります。そのうちセキュリティ研究者にとって興味深いものはごく一部であるため、プロセス全体の最初のステップは、監視すべき関数を特定することです。次に、フックに使うシンボルを見つける必要があります。

これは、オブジェクトファイルからシンボルを一覧表示するために作られたCLIツールであるnmを使って実現できます。

DummyModule.swift内のdummyFunctionを計装したいとします。

public class Dummy {
    public class AnotherDummyClass {
        public func dummyFunction(intDummyArg: Int, stringDummyArg: String, booleanDummyArg: Bool) -> String {
            return "I am just a dummy function."
        }
    }
}

コンパイルしてnm -g DummyModuleコマンドを実行すると、シンボルは次のようになります。

$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF

Swiftの名前マングリング

シンボル解決は、コンパイラー設計におけるリンカーの役割の一つです。あるファイルで宣言されたシンボルを別のファイルにあるそのシンボルへの参照と照合し、オブジェクトファイル間のシンボル参照を解決します。 Cのような言語では、ある名前(シンボル)を持つ関数やデータ項目は常に一つしか存在できないため、名前マングリングは必要ありません。同じクラス上で、シグネチャの異なる類似のセレクターのオーバーロードやテンプレート化を許す言語では、事情が複雑になります。

例:

    int add(int a, int b)  

    float add(float a, float b)

コンパイラーはシンボルをマングリングします。つまり、リンカーが理解できる一意の識別子を関数に与えます。 最初に直感的に思いつくのは、add(int, int)->intのように完全なシグネチャを使う方法です。しかしこれでは、リンカーに大量の追加コードが必要になるうえ、unsignedとunsigned intのように複数の型名が同じ基底型に対応する場合に混乱が生じます。 Swiftの名前マングリングは本記事の主題ではないため、先ほどのdummyFunctionの例を通じて、そのルールの一部を説明します。

public class Dummy {
    public class AnotherDummyClass {
        public func dummyFunction(intDummyArg: Int, stringDummyArg: String, booleanDummyArg: Bool) -> String {
            return "I am just a dummy function."
        }
    }
}

マングリングされたシンボルは次のとおりです。 $s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF

  • $s:グローバルなSwiftシンボルのプレフィックス
  • 11DummyModule:モジュール名。長さは11文字
  • 0A0C:置換が使われるため、識別子は0で始まります。Dummyという単語は、以降の出現箇所では文字Aとして使われます。Dummyが最後の識別子であるため、マングリングされた形を別の0で終え、最後のCはClassを扱っていることを意味します。
  • 07AnotherA5ClassC:0は識別子に単語の置換があることを意味し、Anotherが7文字、AはDummyの置換、Classが5文字です。前の識別子と同様に、Cはクラスを扱っていることを意味します。
  • 13dummyFunction:関数名dummyFunction。長さは13文字
  • 03intA3Arg:0は置換、intが3文字、AはDummyの置換、最後にArgの3文字です。
  • 06stringaH0:前の引数と同様です。唯一の違いは、Argが置換される点です。
  • 007booleanaH0:前の引数と同様
  • SS:戻り値の型Swift.String
  • Si:引数の型Swift.Int
  • SS:引数の型Swift.String
  • Sb:引数の型Swift.Bool
  • F:最後のシンボルは、これがFunctionのシンボルであることを意味します。

以下は、Swiftシンボルのデマングリング用に設計されたプログラムであるswift-demangleの出力です。

swift-demangleの出力

アプリケーションの準備

Fridaのようなフレームワークを活用し、必要なライブラリを組み込んでFridaGadgetを有効にすることで、アプリケーションを計装できるように準備します。 以下のセットアップにより、カスタムコードの注入とアプリケーションの挙動の監視が可能になります。

  • メインのリリースページから、自分のアーキテクチャに対応するFridaGadet.dylibをダウンロードします。
  • FridaGadget.dylibをアプリケーションのFrameworksフォルダーに移動します。
  • ガジェットのロードコマンドを挿入します。insert_dylibは「Mach-Oバイナリにdylibのロードコマンドを挿入するためのコマンドラインユーティリティ」です。
  • アプリケーションを実行すると、止まったままになるのが想定どおりの動作です。frida-clientがアタッチするのを待っています。
  • ここでは、複数のアプローチが使えます。
    • 素早いプロトタイピングにはfrida-ps
    • 自社アプリケーションを監視する開発者は、シンプルなPythonスクリプトを使ってアプリケーションにアタッチできます。
    import frida
    def on_frida_message(message, data):
        # Callback to execute when a frida-message is received.

    device_id = "your-device-id"
    frida_device = frida.get_device(device_id)
    frida_session = frida_device.attach("gadget")
    script_path = "path/to/instrument.js"
    with open(script_path, "r") as f:
        frida_script = frida_session.create_script(f.read())
        frida_script.on("message", on_frida_message)
        frida_script.load()
  • ユースケースに応じて、on_frida_messageコールバックの本体は、単にターミナルに出力するだけのものから、スタックトレースをファイルに保存するもの、解析のためにセキュリティルールに通すものまでさまざまです。
  • パズルの欠けているピースは、instrument.jsの中身です。関数が傍受されたとき、具体的に何をするのでしょうか。この問いに答えるには、いくつかのポイントを理解する必要があります。

計装コード

Swift ABI:アプリケーションバイナリインターフェース

実行時、Swiftプログラムのバイナリは、ABI(Application Binary Interface、アプリケーションバイナリインターフェース)を通じて他のライブラリやコンポーネントとやり取りします。ABIは、個別にコンパイルされたバイナリエンティティがリンクされて一緒に実行されるために準拠しなければならない仕様です。

これらのバイナリエンティティは、多くの低レベルの詳細について合意している必要があります。関数をどのように呼び出すか、データがメモリ上でどのように表現されるか、さらにはメタデータがどこにあり、どのようにアクセスするかといったことです。関数は互いを呼び出す方法も知っていなければならず、それにはコールスタックのレイアウト、どのレジスターが保存されるか、所有権の規約などが含まれます。

呼び出し規約

以下は、ARM64とx86-64のレジスター使用表の一部です。完全な一覧と詳細については、SwiftのGitHubリポジトリを参照してください。

ARM64

レジスター 特殊 用途 Swift
x0 整数引数1(1番目の戻り値)
x1 整数引数2(2番目の戻り値)
x2 - x7 整数引数3-8
x8 間接結果の格納先レジスター
x16 ip0 スクラッチレジスター
x17 ip1
x18 予約済み、使用禁止
x19 呼び出し先保存レジスター self
.. .. ..... ..

X86-64

レジスター 用途 Swift
rax 戻り値。可変長引数の場合は、使用するxmmレジスターの数も示す
rbx 呼び出し先保存レジスター
rdi 整数引数1
rsi 整数引数2
rdx 整数引数3(2番目の戻り値)
rcx 整数引数4(3番目の戻り値)
.. ..... ..

引数

関数のパラメーターがどこに格納されるかが分かったので、それらを抽出してみましょう。

ブール値

ブール値には、その引数を保持するレジスターを直接読み取ることでアクセスできます。

/** Get the boolean argument of a function.
 * 
 * @param context Frida context giving access to register values.
 * @param argIndex Argument index to determine which offset is the arg pointer.
 * @returns The boolean value of the argument as an integer.
 */
function GetSwiftBoolArgument(context, argIndex, swiftRegisterShiftingIndex) {
    argIndex+=swiftRegisterShiftingIndex
    let register = getSwiftArgumentCorrespondingRegisterForARM64(context, argIndex);
    return Boolean(register.and(0x1).toInt32())
}

整数

ブール値と同様に、整数もレジスターの値にアクセスするだけで取得できます。

/** Get the integer argument of a function.
 * 
 * @param context Frida context giving access to register values.
 * @param argIndex Argument index to determine which offset is the arg pointer.
 * @returns The integer value of the argument.
 */
function GetSwiftIntArgument(context, argIndex, swiftRegisterShiftingIndex) {
    argIndex+=swiftRegisterShiftingIndex
    let register = getSwiftArgumentCorrespondingRegisterForARM64(context, argIndex);
    return  register.toInt32();
}

文字列

SwiftのStringが関数に渡されるとき、そのサイズに応じて2通りの渡し方があります。サイズが16バイト未満であれば文字列はスタック上で渡され、そうでなければヒープ上で渡されます。 ただし、サイズにかかわらず、オブジェクト自体は次の図に示す構造に従います。

Swiftの文字列の構造

16バイト未満の場合

文字列の長さは最大16バイトです。64ビットアーキテクチャではレジスターは64ビットであるため、文字列を保持するには2つのレジスターが必要です。 この事実によって、先ほど見た呼び出し規約の表の理解はがらりと変わります。関数が2つの引数を取り、1つ目がSwift.String型、2つ目がSwift.Int型である場合、このルールは整数引数にのみ適用されるのでしょうか。

    ```
    rdi:  Integer argument 1
    rsi:  Integer argument 2
    ```

ルールが整数引数にのみ適用されるのでなければ、最初のレジスターを文字列の格納に使うのでしょうか。シフトするのでしょうか。引数が複数ある場合、すべてがシフトされるのでしょうか。

この問いに答えるには、アプリケーションを実行し、LLDBでアタッチしてレジスターの値をのぞいてみるだけで十分です。

  • まず、マングリングのセクションで見たダミーモジュールに対してlldbを実行します。
-> ~ lldb DummyModule
(lldb) target create "DummyModule"
  • 「dummyFunction」関数にブレークポイントを設定します。
(lldb) breakpoint set --file main.swift --line 37
Breakpoint 1: where = DummyModule`$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF + 58 address = 0x00000000000c446a
  • プログラムを実行します。
(lldb) run
Process 3162136 launched: '/home/haddadi/Documents/swift-nio/.build/install/DummyModule' (x86_64)
Process 3162136 stopped
* thread #1, name = 'DummyModule', stop reason = breakpoint 1.1
    frame #0: 0x000055555561846a DummyModule`$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF at main.swift:37:20
  • 1つ目の引数は整数型で、「rdi」レジスターにあるはずです。
(lldb) register read rdi -f d
    rdi = 42
  • 2つ目の引数は文字列型で、2番目のレジスター「rsi」にあります。
(lldb) register read rsi -f s
    rsi = "42"
  • 次のレジスター「rdx」です。
(lldb) register read rdx -f s
    rdx = ""

(lldb) register read rdx -f b
    rdx = 1110001000000000000000000000000000000000000000000000000000000000

最初のバイト11100010は、オブジェクトに関するメタデータを保持しています。

  • b63:isImmortal。SwiftランタイムがARCをスキップすべきかどうか。小さな文字列は単なる値であり、常にimmortalです。
  • b62:(大きな文字列)isBridged/(小さな文字列)isASCII
  • b61:isSmall。小さな文字列を示す専用のビット
  • b60:isForeign。lowとも呼ばれ、連続したUTF-8へのアクセスを提供できないことを示します。
  • 最後の4ビットは文字数を表します。0010 => 2。

仮説を確かめるため、Integer argument 4(上の表を参照)を保持するrcxレジスターの値を、ブール値の形式-f Bで読み取ります。

(lldb) register read rcx -f B
    rcx = true

LLDBでのレジスターの読み取り

16バイトを超える場合

文字列リテラルはヒープ上に割り当てられます。対応するレジスター(この文字列引数のインデックスによって決まる)は、文字列のメタデータと、ヒープ上のリテラルへのポインターを保持します。 下の図のとおり、_objectの8バイトは2番目のレジスターに格納されます。

文字列のメタデータ

これをコードにすると次のようになります。

/** Extract the string value of the argument; case of strings with length > 16 bytes.
 * 
 * @param secondRegister The second register used to hold the _object value.   
 * @returns The string value of argument.
 */
function GetSwiftLargeStringArgument(secondRegister) {
      const ptr2hex = '0x' + secondRegister.toString(16);
      let ptr2value = BigInt(ptr2hex);

   // low 56 bits (check drawing above)
      let strAddress = '0x' + (ptr2value & 0xFFFFFFFFFFFFFFn).toString(16);
      let strPtr = new NativePointer(strAddress);
      let cstrPtr = strPtr.add(32); // Skip the offset (check drawing above)
      const message = cstrPtr.readCString() ?? "";

      return message
}

文字列のサイズに基づくこの扱いの違いは、計装中に正確にデータを抽出するうえで不可欠です。これらの規約を理解して活用することで、研究者は文字列引数を効果的に読み取り、操作でき、アプリケーションの実行時の挙動についてより深い知見を得られます。

まとめ

まとめると、Swiftの動的計装は、ソフトウェアの挙動をリアルタイムで解析・制御できる、セキュリティ自動化のための最先端のアプローチです。 ここまでの段落では、動的計装の主なステップを説明し、名前マングリング、Swift ABI、関数のプリミティブ型の引数がどこに、そして重要なことにどのように格納されるかを取り上げて、Swiftのユースケースに合わせて解説しました。 次回の記事では、非プリミティブ型の引数へと話を広げ、UIKitやAppKitを例に、Objective-Cランタイムで実行される関数の扱い方を解説します。