デバッガの練習のためにRustでCTF(Pwn)問題を作った

画像はCC BY 2.0 DEEDライセンスのもと使用しています。*1

1. はじめに

琉球大学知能情報アドベントカレンダー Advent Calendar 2023の12月5日の記事です。

12月1日の個人的に興味があるRustとWasmについてでバイナリを読む作業があったのですが、思いの外楽しかったので、今回はデバッガの使い方を学ぶために、CTF(Pwn)問題をRustで作成してrust-lldbを用いて解いて行こうと思います。

x86_64アーキテクチャ用のバイナリファイルを解析していきます。

2. デバッガとは

デバッガは、プログラムの実行を制御し、バグやエラーを特定し、修正するためのツールです。デバッガを使用すると、プログラムをステップバイステップで実行したり、特定の条件でプログラムの実行を一時停止したり、変数の値を確認したりすることができます。

デバッガは、プログラムの動作を理解し、問題を特定し、解決するための重要なツールです。特に、複雑な問題や予期しない動作をデバッグする際には、デバッガの使用はほぼ必須となります。

今回扱うデバッガはソースレベルデバッガです。ソースレベルデバッガには、GCCベースのコンパイラではgdbLLVMベースのコンパイラではlldbなどがあります。

RustはLLVMベースで、rust-lldbというデバッガが提供されています。

3. CTFとは

CTF(Capture The Flag)は、コンピュータセキュリティのスキルを競う競技の一つです。参加者は、様々なセキュリティ問題を解決し、"フラグ"と呼ばれる特定の文字列を見つけ出すことでポイントを獲得します。問題は、Webセキュリティ、暗号学、ネットワークセキュリティ、ディスアセンブル、バイナリ解析など、様々なカテゴリに分けられます。

今回作成するCTF問題は、Pwn問題と呼ばれるカテゴリのものです。

Pwn問題は、バイナリ解析とエクスプロイト作成のスキルを試す問題です。与えられたバイナリを解析し、そのバイナリの脆弱性を突くことでフラグを取得します。

4. 実際に解いてみる

バイナリのダウンロードページを用意しました。こちらからダウンロードしてください。

4.1. 問題の内容を確認する

$ ./behemoth0で実行できます。

実行権限が付与されていなければ$ chmod 755 behemoth0を実行してください。

$ ./behemoth0        
Please enter your password:

このようにパスワードを入力するように促されます。

正しいパスワードを入力すると何らかの報酬が貰えることが期待できます。

4.2. stringsコマンドで文字列を抽出してみる

stringsコマンドは、バイナリファイルや他の種類のファイルからASCII文字列を抽出するためのUnix系システムのコマンドです。このコマンドは、ファイル内の印刷可能な文字列を表示します。これは、バイナリファイルの内容を調査する際に特に役立ちます。

実際に実行して文字列を抽出してみます。

$ strings behemoth0

...省略
/rustc/5680fa18feaa87f3ff04063800aec256c3d4b4be/library/core/src/alloc/layout.rsattempt to divide by zeroIEXXOI^BEXYOHK^^OXSY^KZFOPlease enter your password: src/main.rs
qwerty12345answer'Answer'? Well, aren't we playing the philosopher today!
'12345'? I see, you've mastered the art of counting!
Oh, 'qwerty'? How original!
As if it would be that easy!
Oh, choosing to remain silent? How intriguing!
Access denied. Try again.
...省略

このようにpassword, qwerty, 12345のような文字列が抽出できました。

実際に入力してみます。

$ ./behemoth0
Please enter your password: password
As if it would be that easy!
Access denied. Try again.
Please enter your password: qwerty
Oh, 'qwerty'? How original!
Access denied. Try again.
Please enter your password: 12345
'12345'? I see, you've mastered the art of counting!
Access denied. Try again.

煽り文句と共にパスワードが弾かれました。

ネタバレですが、stringsコマンドで抽出できた文字列をパスワードに入れても正解はありません。

実装のコードでは、平文で正解のパスワードが記述されていないようです。

4.3. デバッガを用いて解析する

stringsコマンドではこの問題が解けないことがわかりました。

正解のパスワードはおそらく、実行時に動的に生成されていると考えることができます。

この場合は、バイナリファイルを実行しながらメモリにある変数を調べることができるデバッガを使います。

以下のコマンドを実行します。

$ rust-lldb behemoth0

パスワードが正しいか照合するということは、文字列を比較する処理がどこかにあるはずです。

C言語の場合はstrcmp関数などがありますが、Rustでは==演算子で文字列の比較をします。

==演算子std::cmp::PartialEqトレイトのeqメソッドを呼び出すことで、二つの値の等価性を評価します。*2

eqメソッドが呼ばれる箇所にbreakpointを設置します。

breakpointとは、デバッガがプログラムの実行を一時停止する指定した地点のことです。breakpointを設置することで、その地点での変数の値を確認したり、ステップバイステップでプログラムを実行したりすることができます。

Rustのデバッガであるrust-lldbでは、b (breakpoint set --name)コマンドを使用してbreakpointを設置します。以下のようにeqメソッドが呼ばれる箇所にbreakpointを設置します。

(lldb) b eq
Breakpoint 1: 60 locations.

出力結果は、eqという名前の関数またはメソッドがプログラム内の60箇所で使用されていて、breakpointがそのすべての箇所に設置されたことを示しています。

breakpointを設置したらプログラムをr (run)コマンドで走らせます。パスワードの入力が聞かれるので適当な値を入力します。パスワードを入力し終えるとeqメソッドが呼ばれるまで実行されます。

(lldb) r
Process 3493 launched: '/Users/yoshisaur/workspace/blog/20231205/behemoth0/target/debug/behemoth0' (x86_64)
Please enter your password: password
Process 3493 stopped
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.2
    frame #0: 0x00000001000057a4 behemoth0`_$LT$alloc..string..String$u20$as$u20$core..cmp..PartialEq$GT$::eq::h76fa5ff4362a6554(self=0x00007ff7bfefecb0, other=0x00007ff7bfefec60) at string.rs:366:5
Target 0: (behemoth0) stopped.

eqメソッドが呼ばれた段階で、fr v (frame variable)コマンドを用いて、現在のフレームの変数を表示します。このコマンドは、現在のスタックフレームのすべての変数を表示します。

(lldb) fr v
(alloc::string::String *) self = 0x00007ff7bfefecb0
(alloc::string::String *) other = 0x00007ff7bfefec60

これは、selfotherという2つの変数がメモリ上の特定のアドレスを指していることを示しています。selfは入力されたパスワードを、otherは正しいパスワードを表していると考えるのが自然です。

しかし、これらはただのメモリアドレスなので、直接的な情報は得られません。そのため、これらのアドレスが指す実際の文字列を見るためには、さらにデバッガのコマンドを使用する必要があります。

具体的には、pprint)コマンドを使用して、これらの変数が指す文字列を表示します。

(lldb) p *other
(alloc::string::String) $0 = "ネタバレ防止" {
  vec = size=25 {
    ネタバレ防止
  }
}

答えをネタバレすると面白くないので省略しました。実際に入力して報酬をゲットしてみてください。

一旦デバッガでの作業が終了したのでq(quit)でデバッガから抜けてください。

4.4. stringsコマンドが有効でなかった理由

パスワードは解析できたのでこれで十分ですが、stringsコマンドで文字列を抽出できなかった理由もデバッガで調査しようと思います。

stringsコマンドで文字列を抽出できなかったのは、実行時に動的に正解のパスワードの文字列を動的に生成しているのが原因であると考えることができます。

もし動的にパスワードを生成しているのであれば、文字列を生成する関数またはメソッドが存在しているはずです。

main関数内から探してみましょう。

出力が長いので一旦出力をテキストファイルに書き出してから探すことをおすすめします。

$ rust-lldb behemoth0 2>&1 | tee session.txtでテキストファイルに書き込みながらコンソールにも出力を表示させることができます。

disassemble --name mainをlldbで実行して、main関数をディスアセンブル*3してください。

実行したらlldbから抜けて、$ grep "callq" session.txt | lessを実行してください。

このコマンドは、session.txtファイルからcallqという文字列を含む行を検索し、その結果をlessコマンドで表示します。callqx86_64アーキテクチャアセンブリ言語で関数呼び出しを表す命令です。つまり、このコマンドはmain関数内で呼び出されるすべての関数を探すために使用されます。

behemoth0[0x100004cf7] <+55>:   callq  0x100004c60               ; behemoth0::memfrob::h0a7613b04ef7d854 at main.rs:3
.
.
.
省略
.
.
.
behemoth0[0x100005263] <+19>: callq  0x100004be0               ; std::rt::lang_start::h8203cbc8150ed6fb at rt.rs:159

これらの関数呼び出しの中に、文字列を生成する関数があるはずです。

その中で特に注目すべきは、behemoth0::memfrob::h0a7613b04ef7d854という関数です。

behemoth0クレートがmemfrob関数を独自に定義しているので、標準クレートで実装されていない特殊な処理が行われている可能性があります。

ちなみにh0a7613b04ef7d854の部分は、Rustの名前修飾(name mangling)によって生成された一意の識別子です。Rustでは、コンパイル時に関数名や変数名を一意に識別できるように、名前修飾を行います。これにより、同じ名前の関数や変数が他のスコープで定義されていても、それぞれを一意に識別することが可能になります。

lldbでmemfrob関数が呼ばれている箇所にbreakpointを設置して、実行します。

(lldb) b memfrob
Breakpoint 1: where = behemoth0`behemoth0::memfrob::h0a7613b04ef7d854 + 46 at main.rs:4:5, address = 0x0000000100004c8e
(lldb) r
Process 4695 launched: '/Users/yoshisaur/workspace/blog/20231205/behemoth0/target/debug/behemoth0' (x86_64)
Process 4695 stopped
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x0000000100004c8e behemoth0`behemoth0::memfrob::h0a7613b04ef7d854(input="暗号化前の文字列") at main.rs:4:5
   1    use std::io::{self, Write};
   2
   3    fn memfrob(input: &str) -> String {
-> 4        input.bytes().map(|b| (b ^ 42) as char).collect()
   5    }
   6
   7    fn main() {
Target 0: (behemoth0) stopped.

出力で出たRustコードでネタバレを喰らいましたが、一応、読んでいきましょう。

memfrob関数では、文字列の各バイトを42でXOR演算した結果を新たな文字列として返す関数だと出力のコードからわかります。

Rustのコードは見なかったことにして、デバッガのみでmemfrob関数が正解のパスワードを動的に生成していると発見してみましょう。

memfrob関数が呼ばれた時点のフレームの変数を出力します。

この出力から、memfrob関数の引数inputに文字列(パスワードに関するものかは未確定)が渡されていることがわかります。

現在の関数が終了するまで、プログラムの実行を続けるfinishコマンドを使って、memfrob関数を終了するところまで実行させます。

(lldb) finish
Process 5021 stopped
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = step out
Return value: (alloc::string::String) $0 = "ネタバレ防止" {
  vec = size=25 {
      ネタバレ防止
  }
}

    frame #0: 0x0000000100004cfc behemoth0`behemoth0::main::h537f6aedbc8cd8a6 at main.rs:12:9
   9        let frobbed_password = memfrob(secret_password);
   10
   11       loop {
-> 12           print!("Please enter your password: ");
   13           io::stdout().flush().unwrap();
   14           let mut input = String::new();
   15           io::stdin().read_line(&mut input).unwrap();
Target 0: (behemoth0) stopped.

memfrob関数が動的に正解のパスワードを生成していることがわかりました。

これで、stringsコマンドでバイナリから静的に文字列を解析してもパスワードが見つからない原因が調査できました。

5. Rustのコード

今回の問題は以下のようなコードで実装されました。

use std::io::{self, Write};

fn memfrob(input: &str) -> String {
    input.bytes().map(|b| (b ^ 42) as char).collect()
}

fn main() {
    let secret_password = "ネタバレ防止";
    let frobbed_password = memfrob(secret_password);

    loop {
        print!("Please enter your password: ");
        io::stdout().flush().unwrap();
        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap();
        input = input.trim().to_string();
        match input.as_str() {
            _ if input == frobbed_password => {
                let output = memfrob("ネタバレ防止");
                println!("{}", output);
                break;
            }
            "" => println!("Oh, choosing to remain silent? How intriguing!"),
            "password" => println!("As if it would be that easy!"),
            "qwerty" => println!("Oh, 'qwerty'? How original!"),
            "12345" => println!("'12345'? I see, you've mastered the art of counting!"),
            "answer" => println!("'Answer'? Well, aren't we playing the philosopher today!"),
            _ => println!("Access denied. Try again."),
        }
    }
}

不要に思えるパスワードのパターンマッチング、煽り文句はstringsコマンドの出力を使ったときに問題を解く人を出力で惑わせるために書きました。

Rustでは、バッファオーバーを起こすプログラムを使ってCTF問題を作るのは難しいですが*4、このようにデバッガを用いたリバースエンジニアリングの問題は作れます。

余談ですが、実はGNUのCライブラリには、ジョークとして実装されたmemfrob関数があります。GNUのCライブラリにあるmemfrob関数は、このRustのコードの関数と全く同じ処理をします。今回、RustではGNUのCライブラリを直接呼ぶのは難しかったため、独自実装しました。

6. 参考にしたCTF問題の出典

問題の内容はOverTheWireのbehemothという常設CTFを参考にして作成しました。簡単にチャレンジできて、非常に楽しいので、こちらも解いてみてください。

7. まとめ

この記事ではRustで作成したCTFをrust-lldbという用いて解いてデバッガの使い方を学習して練習しました。

あまりデバッガに日頃触れていない方にとっての学びになったことを祈っております。

実際に、この記事の問題を解いたら、報酬となる出力がコンソールに表示されます。それを僕にDMで教えてください。喜びます。

最後まで読んでくださって、どうもありがとうございました。

*1:Lydia Liu氏の画像をCC BY 2.0 DEEDライセンスのもと使用しています。Campers Playing Capture the Flag

*2:Trait std::cmp::PartialEqのドキュメントを参照

*3:ディスアセンブル(disassemble)とは、バイナリコード(機械語)を人間が理解可能なアセンブリ言語に変換することを指します。

*4:そもそもNXビット、XDビットなどの実行禁止ビットをサポートしているプロセッサでは作れない