Rustでx86_64のELF用のタイムトラベルデバッガを作る

#tech#Rust

2024-4-3

お断り

本記事は,現在所属の1つである,筑波大学情報学群情報科学類において履修した,情報システム実験Bでの最終課題で執筆したレポート[1]の体裁を変更したものになっている[2].この実験の履修の仕組みや全体の様子などは別記事に既に書いたので適切に参照されたい.

  • Rustで簡単なタイムトラベルデバッガを書いた
  • (現在のところ)x86_64上のELFを対象とする

モチベーション

情報システム実験Bは,自分の興味に合わせて,開講されているテーマの内から春学期と秋学期に1つずつ選択する形式であり,秋学期には"バイナリプログラムの解析"を選択したため,割とバイナリ解析をやることになった(冒頭のリンク先にも記載があるが,実際には周辺のテーマについても広く扱った).これまでCTF等を通してある程度バイナリ解析をやったことはあったが,各回で行った,実際にバイナリ解析に有用なツールを自らで作成してみるという経験には乏しかったというのが実状である.

そんな中,最終課題として次の要件が示された:

バイナリプログラムの解析に関連する既存のツールや機構のクローン,改造版,機能限定版などを実装する. 実装するのは,解析する側のツールや機構でもよいし,解析される(ことを回避する)側のツールや機構でもよい. いわゆる「〇〇もどき」「なんちゃって〇〇」「〇〇インスパイア」と呼ばれるツールを作る.車輪の再発明をする. 実装を通じて解析ツールや解析に関連する知識についての理解を深める.

講義の期間に実装と発表資料を完成させるので,個人的にはこまめにマイルストーン的なやつを設けられるものの方がやりやすいと考えた.この思考の結果としてデバッガ(本記事では"デバッガ"という語はバイナリプログラムを対象としたデバッガのことを指す)という案が自分の中で浮上した.デバッガというのは一般に主要な機能としてブレークポイントの設置やレジスタに格納されている値のダンプといった機能を持ち,それらを1つずつ完成させていけば全体の設計を比較的やりやすいと考えたからである.

しかしごく一般的な機能を有するデバッガを作るだけでは味気ない(当社比,要出典).そこで面白ネタを探していたところ,SeaQL/FireDBG.for.Rustの存在を知った.これはSeaQLというRustコミュニティによって作られている,Rust製のタイムトラベルデバッガである.タイムトラベルデバッガ及びFireDBGの概要については後述するが,これを知り,またこのソフトウェアに対する欠点も存在する(これも後述)と感じたので,自分でタイムトラベルデバッガを作成してみることにした.

タイムトラベルデバッガ

概要

通常,デバッガは対象となるプログラムを実行し,その過程で得られる様々な情報をユーザーに開示するソフトウェアとなっている.しかし例えば,ステップ実行を行っている途中で以前のステップの状態に戻りたいといった要望がしばしば発生する.通常であればもう一度そのプログラムをデバッガがアタッチした状態で実行し,動作を再度確認するということになるが,この様な状況において,一般には過去に生じた現象の再発は保証されない[3]

これを再度最初から実行することなく,また現象の再発を保証できる[4]ようにするというのが,タイムトラベルデバッガの基本的なコンセプトである.これは原理的には,戻りたい地点におけるレジスタとメモリの状態を記録しておいて,実際にその地点に戻る際にそれらの情報を用いて状態を回復させることによって実現される.

できること

現状ではざっと以下

  • バイナリダンプ(xxdコマンドライク)
  • 逆アセンブル
  • ブレークポイントの設置
  • レジスタに格納されている値のダンプ
  • タイムトラベルデバッグ

ただし逆アセンブルについては,課題の章でも述べるが,単にcapstone-rust/capstone-rsを用いているだけ.

著名なデバッガとの違い

ここでは,既存のバイナリプログラムに対するデバッガと比べて,どの様な点で差異があるのかを見ていくこととする.

gdb

恐らく著名なデバッガの中でも有数の歴史を持っているのではなかろうか.gdb単体ではタイムトラベルデバッグの機能を持たないが,rrというプラグインを入れることにより,タイムトラベルデバッガとしての機能を持つようになる.しかしそもそもgdbがMac OS上に入れるのは複雑であるという問題がある.

lldb

LLVMのコンパイラから生成されるプログラムのデバッグを対象としているデバッガ.タイムトラベルの機能なし.

SeaQL

前述したが,SeaQLというRustコミュニティによって開発されているタイムトラベルデバッガである.CLIとVSCode上の拡張機能の2つの形態があり,後者はGUIを用いて分かりやすい情報提示が可能.しかしいずれにせよ,Rustのプログラムから中間言語を介してデバッグ情報を生成するため,Rustプログラムのデバッグにしか向かない(一応他の言語にも対応しようという動きはあるっぽい).

まとめ

よって実はタイムトラベルの機能を持ち,汎用的に用いることのできるデバッガはほとんどないのではないかという結論に至ったため,作ってみようと思った.

実装

ここでは,実際の実装を掻い摘んで説明する.

ASLRの無効化

ASLR(Address Space Layout Randomization)は,バイナリプログラム中での関数や変数のアドレスをランダムに配置する機構のことを指す.これがあるとバッファオーバーフロー脆弱性を突きにくくなったりと,セキュリティ的に重要な機構ではあるが,反面デバッガがアタッチする際には,例えばブレークポイントを特定の関数の先頭に設置する際に,事前にその関数のアドレスを知っておかなければならないため,ASLRの機能は邪魔になる.

よってデバッガ側からするとASLRを無効にしてからデバッグを開始したいということになるのだが,これはpersonalityシステムコールを用いることで可能になる.personalityシステムコールは,プロセスの各種設定を行うことのできるシステムコールであり,ADDR_NO_RANDOMIZEをセットすればそのプロセス内でのASLRを無効にすることができる.

let pers = nix::sys::personality::get().unwrap();
if let Err(e) =
    nix::sys::personality::set(pers | nix::sys::personality::Persona::ADDR_NO_RANDOMIZE)
{
    panic!("failed to diable ASLR: ERRNO={e}");
}

ASLRを無効にした後は,このプロセスからforkしたプロセスに対して,execvシステムコールを呼び出すことで,デバッグ対象のプログラムを実行すればよい:

let pid = unsafe { nix::unistd::fork() };

let pid = match pid {
    Ok(fork_result) => fork_result,
    Err(e) => panic!("failed to fork: ERRNO={e}"),
};

match pid {
    nix::unistd::ForkResult::Parent { child } => memento(child, &args.target),
    nix::unistd::ForkResult::Child => target(
        std::path::Path::new(&args.target),
        &args.args.iter().map(|s| &**s).collect::<Vec<&str>>(),
    ),
}

ブレークポイント

x86_64アーキテクチャにおいては,INT3命令と呼ばれる命令を用いるとソフトウェア割り込みを発生させることができる.これは実際には0xccというバイト列により表現される.よってブレークポイントを設置したい地点のバイトを0xccに置き換えて実行するとプログラムをその位置で停止させることができる.INT3命令が実行されるとSIGTRAPというシグナルが発出されるから,デバッガはこれを検知することにより,プログラムのブレークポイントへの到達を検知できる.

よって,プログラムがブレークポイントに到達した際,デバッガは次のような処理を行えばよい:

  • 各レジスタの情報などを開示(必須ではないが)
  • プログラムの元の命令に置き換える
  • プログラムカウンタを1つ戻す
  • レジスタを復元する
  • プログラムカウンタを1つ進める
  • ブレークポイントを再度復元する

実際にブレークポイントを設置するにはptraceシステムコールを用いれば良い.

#[derive(Debug)]
pub struct BPointOp {
    pid: nix::unistd::Pid,
    bpoint: Vec<BPoint>,
}

impl BPointOp {
    pub fn new(pid: nix::unistd::Pid) -> Self {
        Self {
            pid,
            bpoint: Vec::new(),
        }
    }

    pub fn set(&mut self, addr: u64) -> Result<u8, Box<dyn std::error::Error>> {
        nix::sys::wait::wait().unwrap();
        let read = match nix::sys::ptrace::read(self.pid, addr as *mut nix::libc::c_void) {
            Ok(res) => res,
            Err(e) => {
                panic!("failed to read memory: ERRNO={:?}", e)
            }
        };

        let mut read_vec = read.to_le_bytes();

        let head = read_vec[0];
        read_vec[0] = 0xcc;
        let mut write = 0;
        for (i, item) in read_vec.iter().enumerate() {
            write += (*item as u64) << (i * 8);
        }

        unsafe {
            nix::sys::ptrace::write(
                self.pid,
                addr as *mut nix::libc::c_void,
                write as *mut nix::libc::c_void,
            )?;
        }

        self.bpoint.push(BPoint::new(addr, head));
        Ok(head)
    }
}

実行結果の外部ファイルへの出力

ときに,デバッガの実行結果を外部ファイルに吐き出したいときがある.そのような時にはstd::io::Writeトレイトを拡張した次のような関数を作っておき:

pub fn write_to_output<W: std::io::Write>(output: &mut W, message: &str) -> std::io::Result<()> {
    write!(output, "{}", message)?;
    Ok(())
}

これを用いて引数に応じて適宜出力ハンドラを変更すれば良い:

let mut output_handle: Box<dyn std::io::Write> = if !filename.is_empty() {
    Box::new(File::create(filename).unwrap())
} else {
    Box::new(std::io::stdout())
};

Util::write_to_output(&mut output_handle, res_str).unwrap();

課題

逆アセンブルの自炊

現状,逆アセンブルはcapstone-rust/capstone-rsを用いて行っているが,これを自前で行いたいという気持ちがある.

GUIなどによる情報提示

FireDBGのVSCode拡張機能版がそうであるように,GUIで情報を分かりやすく提示できると嬉しそう.

多様なアーキテクチャ等への対応

x86_64以外のアーキテクチャ,ELF以外のファイルタイプへの対応をしたい(辛そうだが...).

脚注
  1. これを提出したのは2ヵ月ほど前 ↩︎

  2. 実際にはレポートの文章をほとんど見ずに書いていて,目次のセクション構造を参考にしているくらいだが同じ人間が書いているので多分割と同じ. ↩︎

  3. まあ割と多くのケースでは再発しそうだけど ↩︎

  4. 正確には,"プログラム外のファイル等に副作用を伴わない現象の再発を保証できる"というのが正しい ↩︎