All Articles

angr のフック機能を使い、非想定解で Flag を取りに行く in X'mas Eve

この記事は CTF Advent Calendar 2023 用の記事として作成しました。

前回の記事は Edwow Math(N30Z30N) さんの「良いCTF・悪いCTF・普通のCTF(for CTF Advent Calendar 2023) - Learning cyber security by playing and enjoying CTFs」でした、

次回の記事は、、、残念ながら今のところ記載予定の方がいらっしゃらないようですので割愛します。。

今回は angr で任意のシンボル関数をフックしてその処理を一部変更したり処理をオーバーライドしたりする操作を試してみます。

もくじ

angr の hook_symbol メソッドを使う

angr では hook_symbol メソッドを使うことで特定のシンボル関数をフックすることができます。

hook_symbol によりシンボル関数をオーバーライドする基本的な方法は、angr.SimProcedure クラスを継承した任意のクラスを作成し、その中の run メソッドをフックしたい関数のシンボル名を指定して呼び出すことです。

この時、run メソッドには任意の引数を与えることもできます。

from angr import Project, SimProcedure
project = Project('examples/fauxware/fauxware')

class BugFree(SimProcedure):
   def run(self, argc, argv):
       print('Program running with argc=%s and argv=%s' % (argc, argv))
       return 0

# this assumes we have symbols for the binary
project.hook_symbol('main', BugFree())

# Run a quick execution!
simgr = project.factory.simulation_manager()
simgr.run()

参考:Hooks and SimProcedures - angr documentation

SimProcedures と hook_symbol を使用すると、例えば任意の関数やライブラリ関数の動作などをオーバーライドして Flag を取得できるようになります。

angr でシンボル関数の戻り値をオーバーライドして Flag を取得する

初めに、以下のような C のコードで作成したバイナリを angr で解析して Flag を取得してみます。

以下のコードをコンパイルしたプログラムは、標準入力として文字列を受け取り、受け取った文字列が Flag{angr} であり、かつ rand() 関数で生成した数値が 0x12345 の場合にのみ Success というテキストを出力するプログラムです。

// gcc sample1.c -o chal.bin
#include <stdio.h>
#include <string.h>
#include <time.h>

void main()
{
    char flag[16];
    scanf("%15s", flag);
    srand(time(NULL));

    if ((strcmp(flag, "Flag{angr}") == 0) && (rand() == 0x12345)) {
        printf("Success\n");
    } else {
        printf("Failed\n");
    }
    return 0;
}

普通に実行しても rand() 関数の戻り値が 0x12345 ちょうどになることはまずないため、出力は常に Failed になります。

つまり、rand() 関数の戻り値が 0x12345 ちょうどになるという制約を満たさない限り、angr で正しい Flag を特定することができません。

このような場合には、以下のようなスクリプトで rand 関数をフックして戻り値を 0x12345 にオーバーライドすると Flag を取得できます。

以下の例では、flag というシンボル変数で定義したベクタを標準入力としてセットしたあと、hook_symbol メソッドで rand 関数のオーバーライドを行うことで、正しい Flag が Flag{angr} であることを簡単に特定できます。

import angr
import claripy
from logging import getLogger, WARN

getLogger("angr").setLevel(WARN + 1)

class OverrideFunction(angr.SimProcedure):
    def run(self, argc, argv):
        data = (0x12345).to_bytes(4, byteorder='little')
        data = int.from_bytes(data, byteorder='big')
        return claripy.BVV(data, 32)

def correct(state):
    if b"Success" in state.posix.dumps(1):
        return True
    return False

def failed(state):
    if b"Failed" in state.posix.dumps(1):
        return True
    return False

flag = claripy.BVS('flag', 16*8, explicit_name=True)
proj = angr.Project("./chal.bin", load_options={"auto_load_libs": False})

state = proj.factory.entry_state(stdin=flag)
simgr = proj.factory.simulation_manager(state)
simgr.explore(find=correct, avoid=failed)

proj.hook_symbol("rand", OverrideFunction())

try:
    found = simgr.found[0]
    # print(found.posix.dumps(0))
    print(found.solver.eval(flag, cast_to=bytes))
except IndexError:
    print("Not Found")

参考:angrをいろいろ使ってみる【yoshi-camp備忘録】 - CTFするぞ

ここでは、rand 関数が OverrideFunction クラスの run メソッドで定義された内容に置き換えられるそうです。

戻り値として整数を返す場合は、任意の整数を big エンディアン化したベクタにする必要があるようでしたので、以下のコードで 0x12345 という整数を big エンディアン形式のバイト列に変換した後、さらにそれを整数に置き換えてベクタを作成しています。

data = (0x12345).to_bytes(4, byteorder='little')
data = int.from_bytes(data, byteorder='big')
claripy.BVV(data, 32)

これを実行すると、最終的に Success が出力された際のシンボル変数 flag がダンプされ、正しい Flag を特定できます。

image-20231223053728661

静的リンクされたライブラリ関数の処理を置き換える

angr には、libc などの一般的なライブラリ関数の代替機能がバンドルされているようです。

angr のドキュメントによると、angr ではこれを使用して libc などの代替を行うことで、動的リンクされたプログラムも正常に解析できるようになっています。

参考:Extending the Environment Model - angr documentation

ここで、以下のブログ記事には、バイナリにライブラリ関数が静的リンクされている場合は、リンクされたライブラリ関数まで angr が解析することで、処理が遅延することについて言及されています。

このような場合には、hook_symbol メソッドでライブラリ関数のシンボルと前述の angr にバンドルされたライブラリ関数の代替機能を紐づけてあげることで解析処理の高速化が可能なようです。

p.hook_symbol("__libc_start_main", angr.SIM_PROCEDURES["glibc"]["__libc_start_main"]())
p.hook_symbol("printf", angr.procedures.libc.printf.printf())
p.hook_symbol("__isoc99_scanf", angr.procedures.libc.scanf.scanf())
p.hook_symbol("strcmp", angr.procedures.libc.strcmp.strcmp())
p.hook_symbol("puts", angr.procedures.libc.puts.puts())

参考:angrをいろいろ使ってみる【yoshi-camp備忘録】 - CTFするぞ

angr の hook メソッドを使う

前述の SimProcedure を使用する場合、解析時には関数全体がフックされるようです。

一方で、user hook を使用すると、コードの特定の箇所をフックしてレジスタの置き換えなどを行うことができるようになります。

参考:Hooks and SimProcedures - angr documentation

hook メソッドで特定のレジスタを書き換える

実際に以下のコードをコンパイルしたバイナリで angr の hook メソッドを試していきます。

// gcc sample2.c -o chal.bin
#include <stdio.h>
#include <time.h>

int func()
{
    return 0;
}

void main()
{
    int v = func();

    char flag[16];
    scanf("%15s", flag);
    
    if ((strcmp(flag, "Flag{angr}") == 0) && (v == 1)) {
        printf("Success\n");
    } else {
        printf("Failed\n");
    }
    return 0;
}

上記のプログラムは、入力された文字列が Flag{angr} であるかを検証しますが、変数 v の値が 1 以外の場合には正しい Flag を入力していても Success が出力されません。

そして、変数 v は func 関数によって常に 0 になります。

このようなバイナリの Flag を angr で取得したい場合には、もちろん先ほどと同じように hook_symbol メソッドで func 関数のオーバーライドを行うことで実現できそうです。

ただし、今回はあえて関数のすべての処理をオーバーライドしてしまわないように、hook メソッドで戻り値の eax レジスタのみを書き換えて Flag を取得したいと思います。

まずは事前に objdmp コマンドなどで、func 関数の戻り値を変数 v に格納するコードのアドレスを確認します。

11d8:       e8 cc ff ff ff          call   11a9 <func>
11dd:       89 45 dc                mov    %eax,-0x24(%rbp)

続いて、以下のスクリプトを使用して hook メソッドで戻り値の eax レジスタのみを書き換えることで Flag を取得します。

以下のコードは先ほどのコードと類似していますが、func 関数を呼び出すアドレス 0x4011d8 から 5 バイト分の処理をオーバーライドして、eax レジスタの値を 1 にセットした後、0x4011dd 以降の処理を再開させています。

import angr
import claripy
from logging import getLogger, WARN

getLogger("angr").setLevel(WARN + 1)

def correct(state):
    if b"Success" in state.posix.dumps(1):
        return True
    return False

def failed(state):
    if b"Failed" in state.posix.dumps(1):
        return True
    return False

flag = claripy.BVS('flag', 16*8, explicit_name=True)
proj = angr.Project("./chal.bin", load_options={"auto_load_libs": False})

@proj.hook(0x4011d8, length=5)
def set_eax(state):
    state.regs.eax = 1

state = proj.factory.entry_state(stdin=flag)
simgr = proj.factory.simulation_manager(state)
simgr.explore(find=correct, avoid=failed)

try:
    found = simgr.found[0]
    # print(found.posix.dumps(0))
    print(found.solver.eval(flag, cast_to=bytes))
except IndexError:
    print("Not Found")

このスクリプトを実行することで、v == 1 の検証を突破することができるようになるので、angr で正しい Flag を取得することができました。

image-20231223230810007

hook_symbol を使用して CTF の問題を解く

最後に、angr の hook_symbol メソッドを使用して CTF の問題を解いていきます。

今回 angr を使用して解く問題は、Gracier CTF 2023 の SOP という問題です。

参考:Gracier CTF 2023 Writeup SOP(Rev)

この問題自体は SOP(おそらく Signal Oriented Programming もしくは Sigreturn Oriented Programming) をテーマにしている問題で、main 関数の処理の終了時に例外を発生させるところから始まり、signal 関数で定義したハンドラ関数を raise 関数で次々に呼び出しています。

プログラムの実際の動作(入力値が正しい Flag であるか否かの検証)は main 関数の終了後の例外発生から始まるので、gdb を普通に使用しても動的解析を行うことができず、Frida hook や静的解析で Flag を特定する必要がある問題でした。

今回は、この問題の別解として、angr の hook_symbol によって signal 関数や raise 関数の処理をオーバーライドすることで、実際のバイナリの挙動を無視して正しい Flag を取得する方法を実践していきます。

angr で SOP の Flag を取得する方法

今回のバイナリは、入力値として受け取った文字列が正しい Flag に一致するかどうかを検証するプログラムです。

そのため、通常であれば angr の SimulationManager によって比較的容易に解けるタイプの問題です。

ただし、このバイナリの Flag 検証処理は main 関数の終了後の例外から始まり、signal 関数で定義したハンドラ関数をたどっていく方式で実装されているため、ただ SimulationManager を走らせるだけでは解くことができません。

そこで、Ghidra でバイナリを解析して raise 関数の引数として渡されるシグナル番号と、バイナリ内で定義されているハンドラ関数の対応を特定した上で、hook_symbol メソッドによりプログラムの処理をオーバーライドすることで SimulationManager による Flag の特定を行います。

これによって、angr で signal 関数や raise 関数の処理を実際に解析する必要はなくなり、正しい Flag を簡単に特定できるようになります。

バイナリの解析を行う

angr を使用した Solver を作成するために、事前に Ghidra を使用して Flag の取得に必要な情報を特定しておきます。

まず、main 関数では read 関数で標準入力から 0x44 バイト分の入力を受け取っており、DAT_001061c8 != 0x44 で比較を行った結果を返却していることがわかります。

bool main(void)
{
  size_t sVar1;
  ssize_t sVar2;
  
  sVar1 = strlen(&DAT_001060f0);
  DAT_001061c8 = (int)sVar1;
  if (DAT_001061c8 == 0) {
    sVar2 = read(0,&DAT_001060f0,0x44);
    DAT_001061c8 = (int)sVar2;
  }
  return DAT_001061c8 != 0x44;
}

この main 関数は __libc_start_main から呼び出されていますが、__libc_start_main で呼び出した main 関数の戻り値はそのまま exit 関数に渡されます。

参考:_libcstart_main

つまり、入力した文字数がDAT_001061c8 != 0x44 の比較が False になった場合、プログラムは異常終了したとみなされ、最初のトリガーとなる例外が発生することがわかります。

次に、シグナル番号とハンドラ関数の対応を特定します。

Ghidra で一通りの関数のデコンパイル結果を見ていくと、以下の signal 関数の定義が行われていることを確認できます。

なぜか SIGSEGV(0xb) のハンドラ関数を設定する箇所が複数存在していますが、ここでは動的なハンドラ関数の変更が行われていそうです。

signal(0xb,FUN_001011e0);
signal(0xb,FUN_00101bc0);
signal(0xb,FUN_00102e60);
signal(0xe,FUN_00101bc0);
signal(0x10,FUN_00101350);
signal(0x11,FUN_001014a0);
signal(0x12,FUN_00102fb0);
signal(0x15,FUN_00101550);
signal(0x16,FUN_00102520);

続けて、正しい Flag や誤った Flag を入力した場合の結果を特定します。

これは適当にプログラムを実行してみると FAIL という文字列が出力されることから容易に特定できます。

image-20231224003011199

上記のように、正しい Flag の場合の出力は SUCCESS となり、誤った Flag の場合は FAIL が出力されることがわかりました。

また、Flag の検証を実施している箇所のコードをさらに見ていきます。

if (0x40 < DAT_001061c8) {
    DAT_001061c8 = DAT_001061c8 - 0x40;
    DAT_00106210 = DAT_00106210 + 0x40;
    DAT_001061c0 = DAT_001061c0 + 0x40;
    raise(0xb);
    return;
}
if (DAT_001061c8 < 0x40) {
    for (DAT_001061cc = 0; DAT_001061cc < DAT_001061c8; DAT_001061cc = DAT_001061cc + 1) {
      *(undefined *)(DAT_00106138 + (ulong)DAT_001061cc) = DAT_00106210[DAT_001061cc];
    }
}
DAT_001060d0 = DAT_001061a4;
DAT_001060d4 = DAT_001061ac;
memcpy(local_58,&DAT_00104050,0x44);
memset(local_168,0,0x110);
local_16c = 0;
do {
    do {
      if (local_16c == 0x44) {
        printf("SUCCESS\n");
                    /* WARNING: Subroutine does not return */
        exit(0);
      }
      local_170 = 0;
      getrandom(&local_170,4,2);
      local_170 = local_170 % 0x44;
    } while (local_168[local_170] == 1);
    local_168[local_170] = 1;
    local_16c = local_16c + 1;
} while ((&DAT_00106220)[local_170] == local_58[local_170]);

printf("FAIL\n");

前半部分は読み飛ばし、以下の箇所に着目します。

ここでは、getrandom 関数で取得したランダムな値を 0x44 で mod した後にその結果を添え字として入力値に何らかの変換を加えた値がハードコードされた検証用のバイト列と一致するかを確認しています。

do {
	do {
**
      local_170 = 0;
      getrandom(&local_170,4,2);
      local_170 = local_170 % 0x44;
    } while (local_168[local_170] == 1);

local_168[local_170] = 1;
local_16c = local_16c + 1;

**

} while ((&DAT_00106220)[local_170] == local_58[local_170]);

恐らく動的解析対策の一環として行われている処理かと思いますが、これも angr での解析を妨害する要因になります。

最後に、strace -e trace=signal を使用して、実際にこのプログラムに 0x44 バイト分の文字列を与えた場合のコールバック関数の動作を追っていきます。

$ echo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | strace -e trace=signal ./app
rt_sigaction(SIGSEGV, {sa_handler=0x55a8e91ed1e0, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGSTKFLT, {sa_handler=0x55a8e91ed350, sa_mask=[STKFLT], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGCHLD, {sa_handler=0x55a8e91ed4a0, sa_mask=[CHLD], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGCONT, {sa_handler=0x55a8e91eefb0, sa_mask=[CONT], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0xe91ef160} ---
tgkill(29240, 29240, SIGSTKFLT)         = 0
--- SIGSTKFLT {si_signo=SIGSTKFLT, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
tgkill(29240, 29240, SIGCHLD)           = 0
--- SIGCHLD {si_signo=SIGCHLD, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
tgkill(29240, 29240, SIGCONT)           = 0
--- SIGCONT {si_signo=SIGCONT, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
rt_sigaction(SIGSEGV, {sa_handler=0x55a8e91eee60, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=0x55a8e91ed1e0, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, 8) = 0
tgkill(29240, 29240, SIGSEGV)           = 0
rt_sigreturn({mask=[SEGV STKFLT CHLD]}) = 0
rt_sigreturn({mask=[SEGV STKFLT]})      = 0
rt_sigreturn({mask=[SEGV]})             = 0
rt_sigreturn({mask=[]})                 = 0
--- SIGSEGV {si_signo=SIGSEGV, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
rt_sigaction(SIGTTOU, {sa_handler=0x55a8e91ee520, sa_mask=[TTOU], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGALRM, {sa_handler=0x55a8e91edbc0, sa_mask=[ALRM], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
rt_sigaction(SIGTTIN, {sa_handler=0x55a8e91ed550, sa_mask=[TTIN], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
--- SIGALRM {si_signo=SIGALRM, si_code=SI_KERNEL} ---
rt_sigaction(SIGSEGV, {sa_handler=0x55a8e91edbc0, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=0x55a8e91eee60, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, 8) = 0
tgkill(29240, 29240, SIGTTIN)           = 0
--- SIGTTIN {si_signo=SIGTTIN, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
tgkill(29240, 29240, SIGTTOU)           = 0
--- SIGTTOU {si_signo=SIGTTOU, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
tgkill(29240, 29240, SIGSEGV)           = 0
rt_sigreturn({mask=[SEGV ALRM TTIN]})   = 0
rt_sigreturn({mask=[SEGV ALRM]})        = 0
rt_sigreturn({mask=[SEGV]})             = -1 EINTR (Interrupted system call)
rt_sigreturn({mask=[]})                 = 0
--- SIGSEGV {si_signo=SIGSEGV, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
rt_sigaction(SIGSEGV, {sa_handler=0x55a8e91edbc0, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, {sa_handler=0x55a8e91edbc0, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7f047a881520}, 8) = 0
tgkill(29240, 29240, SIGTTIN)           = 0
--- SIGTTIN {si_signo=SIGTTIN, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
tgkill(29240, 29240, SIGTTOU)           = 0
--- SIGTTOU {si_signo=SIGTTOU, si_code=SI_TKILL, si_pid=29240, si_uid=1000} ---
FAIL
+++ exited with 1 +++

この結果から、最初に main 関数の終了時に SIGSEGV(0xb) が呼ばれており、その時の RVA が 0x11e0 であることがわかります。

さらに、SIGSTKFLT(0x10)、SIGCHLD(0x11)、SIGCONT(0x12) と処理が続いていきます。

全体を通して呼び出されているシグナルは以下でした。

これは、先ほど確認した signal 関数でハンドラ関数が定義されているものと一致しています。(SIGSEGV のハンドラ関数だけは動的に再設定されます)

SIGSEGV(0xb)
SIGALRM(0xe)
SIGSTKFLT(0x10)
SIGCHLD(0x11)
SIGCONT(0x12)
SIGTTIN(0x15)
SIGTTOU(0x16)

ここで気になるのは、raise 関数でトリガーされていない SIGALRM が呼び出されている点です。

少々トリッキーな実装ですが、alarm(1) の直後に sleep(2) を置くことで強制的にこのタイミングで SIGALRM を発生させているようです。

signal(0x16,FUN_00102520);
signal(0xe,FUN_00101bc0);
signal(0x15,FUN_00101550);
alarm(1);
sleep(2);

angr のようなシンボリック実行を利用する解析では、alarm や sleep などの時間経過の状態を正確に追跡できない場合があります。

そのため、Solver 側で sleep 関数をオーバーライドして、強制的に SIGALRM のコールバック関数を呼び出す処理に置き換えてあげる必要があります。

hook_symbol を使用して Solvar を作成する

これで一通りの情報がそろったのでいよいよ Flag を手に入れるための Solver を作成していきます。

この問題は以下の Solver で解くことができます。

import angr
import claripy
from logging import getLogger, WARN

getLogger("angr").setLevel(WARN + 1)

proj = angr.Project("app")
flag = claripy.BVS("flag", 0x44*8)
state = proj.factory.entry_state(stdin=flag)

for i in range(0x44):
    state.solver.add(flag.get_byte(i) >= 0x21)
    state.solver.add(flag.get_byte(i) <= 0x7f)
	
def correct(state):
    if b"SUCCESS" in state.posix.dumps(1):
        return True
    return False

def failed(state):
    if b"FAIL" in state.posix.dumps(1):
        return True
    return False


class OverrideSignal(angr.SimProcedure):
	def run(self, sigid, handler):
		sigid = sigid.concrete_value
		handler = handler.concrete_value
		self.state.globals["handlers"] = self.state.globals["handlers"].copy()
		self.state.globals["handlers"][sigid] = handler
		return 0

class OverrideRaise(angr.SimProcedure):
	def run(self, sigid):
		sigid = sigid.concrete_value
		self.call(self.state.globals["handlers"][sigid], (sigid,), 0xFFFFFFFF)
		
class OverrideSleep(angr.SimProcedure):
	def run(self, sigid):
		sigid = 14
		self.call(self.state.globals["handlers"][sigid], (sigid,), 0xFFFFFFFF)

class OverrideGetRandom(angr.SimProcedure):
	def run(self, val):
		res = self.state.globals["i"]
		if res == 0x44:
			print(self.state.posix.dumps(0))
			input("")
		self.state.globals["i"] += 1
		self.state.mem[val].int = res
		return 0

@proj.hook(0x4031F2)
def first_sigsegv(state):
	state.regs.rsp -= 8
	state.regs.rip = state.globals["handlers"][11]
	state.regs.rdi = 11

state.globals["handlers"] = {
	11: 0x4011E0,
	14: 0x401bc0,
	16: 0x401350,
	17: 0x4014A0,
	18: 0x402FB0,
	21: 0x401550,
	22: 0x402520
}

state.globals["i"] = 0
proj.hook_symbol("signal", OverrideSignal(), replace=True)
proj.hook_symbol("raise", OverrideRaise(), replace=True)
proj.hook_symbol("sleep", OverrideSleep(), replace=True)
proj.hook_symbol("getrandom", OverrideGetRandom(), replace=True)
simgr = proj.factory.simulation_manager(state)

while simgr.active:
    simgr.explore(find=correct, avoid=failed)
    if simgr.found:
        print(simgr.found[0].solver.eval(flag, cast_to=bytes))
        break

実装は一見複雑に見えますが、ここまでに使用してきたものの通りです。

まず冒頭部分の以下のコードでは、いつも通り Project の定義とシンボル変数 flag の宣言、そして、入力が正しい場合と誤っている場合に期待される出力結果を定義しています。

import angr
import claripy
from logging import getLogger, WARN

getLogger("angr").setLevel(WARN + 1)

proj = angr.Project("app")
flag = claripy.BVS("flag", 0x44*8)
state = proj.factory.entry_state(stdin=flag)

for i in range(0x44):
    state.solver.add(flag.get_byte(i) >= 0x21)
    state.solver.add(flag.get_byte(i) <= 0x7f)
	
def correct(state):
    if b"SUCCESS" in state.posix.dumps(1):
        return True
    return False

def failed(state):
    if b"FAIL" in state.posix.dumps(1):
        return True
    return False

次の箇所では、SimProcedure でオーバーライドする 4 つの関数用のクラスを定義しています。

今回は、signal、raise、sleep、getrandom 関数をオーバーライドします。

signal については、すべてのハンドラ関数が固定であれば不要でしたが、SIGSEGV のコールバック関数が動的に変化する実装になっていたので、こちらもオーバーライドして、handlers というハンドラ関数と シグナル番号の対応を定義した辞書にハンドラ関数を追加しています。

また、恐らく動的解析対策で実装されていた getrandom による添え字のランダム化についても、処理をオーバーライドして先頭から 1 ずつ加算した結果を返すようにすることで、正しい Flag を先頭から順に比較するように変更しています。

class OverrideSignal(angr.SimProcedure):
	def run(self, sigid, handler):
		sigid = sigid.concrete_value
		handler = handler.concrete_value
		self.state.globals["handlers"] = self.state.globals["handlers"].copy()
		self.state.globals["handlers"][sigid] = handler
		return 0

class OverrideRaise(angr.SimProcedure):
	def run(self, sigid):
		sigid = sigid.concrete_value
		self.call(self.state.globals["handlers"][sigid], (sigid,), 0xFFFFFFFF)
		
class OverrideSleep(angr.SimProcedure):
	def run(self, sigid):
		sigid = 14
		self.call(self.state.globals["handlers"][sigid], (sigid,), 0xFFFFFFFF)

class OverrideGetRandom(angr.SimProcedure):
	def run(self, val):
		res = self.state.globals["i"]
		if res == 0x44:
			print(self.state.posix.dumps(0))
			input("")
		self.state.globals["i"] += 1
		self.state.mem[val].int = res
		return 0

以下の箇所では User Hook を使用して、main 関数の ret をオーバーライドして SIGSEGV を拾えるようにしています。

@proj.hook(0x4031F2)
def first_sigsegv(state):
	state.regs.rsp -= 8
	state.regs.rip = state.globals["handlers"][11]
	state.regs.rdi = 11

image-20231224130612969

ここでは、スタックポインタなどのレジスタの変更に加えて、rip を SIGSEGV のハンドラ関数に指定することで、例外を回避しつつ処理を先に進めています。

続く以下のコードでは、signal 関数を使用する行から確認できたハンドラ関数の初期値をテーブル化しています。

state.globals["handlers"] = {
	11: 0x4011E0,
	14: 0x401bc0,
	16: 0x401350,
	17: 0x4014A0,
	18: 0x402FB0,
	21: 0x401550,
	22: 0x402520
}

最後に、各シンボル関数をフックし、SimulationManager による解析を実行しています。

state.globals["i"] = 0
proj.hook_symbol("signal", OverrideSignal(), replace=True)
proj.hook_symbol("raise", OverrideRaise(), replace=True)
proj.hook_symbol("sleep", OverrideSleep(), replace=True)
proj.hook_symbol("getrandom", OverrideGetRandom(), replace=True)
simgr = proj.factory.simulation_manager(state)

while simgr.active:
    simgr.explore(find=correct, avoid=failed)
    if simgr.found:
        print(simgr.found[0].solver.eval(flag, cast_to=bytes))
        break

これを実行すると、各ハンドラ関数の中の実装をほとんど解析することなく、正解となる Flag を angr で特定することができます。

image-20231224131017306

まとめ

ちょうど 1 年くらい前に「俺たちはまだ angr を知らない」という記事を書きましたが、今でもまだ angr についてほとんどわかっていないことを痛感しました。

使いこなせば非常に強力な解析ツールになると思うので、引き続き学んでいこうと思います。

参考:俺たちはまだ angr を知らない(Z3py も追記中) - かえるのひみつきち