Simple Flag Checkerの7つの解き方による動的解析入門【AlpacaHack Round 4 作問者writeup】

本記事は2024年10月5日(土)にAlpacaHack上で開催された、AlpacaHack Round 4(Rev)上で出題された"Simple Flag Checker"という問題のwriteupです。

AlpacaHackは個人戦の短期間CTFを継続して開催しつつ、それらの問題を常設することを目標とした新しいCTFプラットフォームです。AlpacaHackで開催される各ラウンドは1ジャンルをテーマとしていて、今回のAlpacaHack Round 4(Rev)はReversingをテーマとしたラウンドでした。私は本Roundに2問の問題を提供し、今回解説する問題である"Simple Flag Checker"はそのうちの一つです。

この問題はその名の通り*1バイナリ自体はシンプルな問題です。もしもまだ問題を見ていない方は、この先を読む前に10分ほどでもよいのでバイナリを眺めることをおすすめします。本writeupは問題を見ていない人でもわかるように書いたつもりではありましたが、自分で多少でも解法を考えるとこの先の話が頭に入りやすくなるはずです。

alpacahack.com

今回のラウンドはReversingの初回ということもあり、様々なReversingのエッセンスを詰め込んだセットにしたいと考えていました。この問題は「デコンパイルや逆アセンブリを読むだけがReversingではない」というメッセージをこめていて、バイナリの動的解析の手法を学ぶ足掛かりになるような問題として作問しました。本writeupでは、動的解析のアプローチやそれに用いる道具の多様さを感じ取っていただるよう、シンプルな解法から少し捻った解法まで様々なバリエーションの解法を紹介します。

ここで紹介した解法を実装したソルバ及び問題自体のソースコードは、以下のGitHubリポジトリで公開しています。


github.com

問題概要

問題の概要と解法の共通する部分について、軽く触れておきます。この問題はx86-64のELF実行ファイルがflag checkerとして与えられ、それが受理するflagを求める問題です。main関数はGhidraなどを用いると簡単にデコンパイルすることができ、以下のような処理となっていることが確認できると思います。

#define FLAG_LEN 49
#define HASH_LEN 36

char table[FLAG_LEN][16] = { FLAG_DATA };

int main() {
  char buf[FLAG_LEN + 1];
  printf("flag? ");
  fgets(buf, FLAG_LEN + 1, stdin);

  char hash[HASH_LEN] = {0};

  int correct = 1;
  for (int i = 0; i < FLAG_LEN; i++) {
    update(hash, buf[i]);
    correct &= memcmp(hash, table[i], 16) == 0;
  }

  if (correct) {
    printf("Correct! Your flag is: %s\n", buf);
    return 0;
  }
  else {
    puts("Wrong...");
    return 1;
  }
}

見て分かるとおり、このプログラムはハッシュを一文字毎にupdateしながらその状態をtableに格納されたバイト列と比較し、すべての文字について合っているならば入力を受理するという動作をしています。しかし、main関数から呼ばれているupdate関数は独自のhash関数を実装しており、最適化がかかっていることや謎のSIMD命令があることも相まってとても静的に動作を解析したい見た目はしていませんし、したとしても36byteある状態の先頭16byteしか比較していないためにtable[i]とtable[i+1]からupdateに用いられたcharを一意に定めることはできません。

↓折りたたみになっているんですが、折りたたみのボタンが出なくて困っています わかりにくくてすいません

▶ 一意に定まらない直感的な理由

現在解きたい問題は、「update関数に渡される引数と更新された状態の先頭16 byteが特定の値となる(引数, 更新された状態, c)の組を見つける」です。updateがhash関数の諸性質(更新された状態、初期値鋭敏性があり、hashはランダムに分布する)を満たすならば*2、この問題の解は一意に定めることができない可能性が非常に高いです。

まず、cを任意の値に固定したときに条件を満たすような値の組が存在するかを考えます。cを「正解」の値に固定した場合は存在することは明らかですが、そうでない場合はどうでしょうか?
直感的には、引数の後半20 byteを自由に調整していいならば更新された状態の先頭16 byteを任意の値にすることはほとんどの場合にできそうです。もう少し砕けた言い方をするとすれば、「256²⁰回ガチャを回せるなら、1/256¹⁶程度の当たりはほぼ確実に引けるだろう」といったところでしょうか。

逆に言えば、hash自体が20 byte程度で自由に調整できる箇所が4 byte程度だった場合は、(効率的にできるかは別問題ですが)高い確率で一意に定めることが可能そうです。


update関数のデコンパイル結果

では解けない……かというとそんなことはなく、flagを一文字ずつ特定していくことが可能です。この問題ではmemcmpが一文字ずつ行われていて、フラグが一致している文字までのmemcmp呼び出しはゼロを返し、それ以降は非ゼロを返します。そのため、この問題を解くためにはmemcmpの戻り値やmemcmpに渡るhashを取得できればよいです。

解法

ltraceを用いて関数呼び出しをフックし、返り値を得る

ltraceはバイナリが行う関数呼び出しをフックして追跡し、呼ばれた関数を引数や返り値と共にstderrに吐き出してくれる便利なプログラムです。以下は、ltraceを用いてcheckerバイナリに適当なフラグを与えつつ動かしたものです。

$ echo Alpaca{fakeflag} | ltrace ./checker > /dev/null
__printf_chk(1, 0x610210a31004, 0x7fff58bfecc8, 0x610210a32da0) = 6
fgets("Alpaca{fakeflag}\n", 50, 0x76120a81aaa0)                 = 0x7fff58bfeb40
memcmp(0x7fff58bfeb10, 0x610210a33020, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33030, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33040, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33050, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33060, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33070, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33080, 16, 9)                   = 0
memcmp(0x7fff58bfeb10, 0x610210a33090, 16, 9)                   = 105
memcmp(0x7fff58bfeb10, 0x610210a330a0, 16, 9)                   = 8
...
memcmp(0x7fff58bfeb10, 0x610210a33310, 16, 9)                   = 0xfffffff3
memcmp(0x7fff58bfeb10, 0x610210a33320, 16, 9)                   = 57
puts("Wrong...")                                                = 9
+++ exited (status 1) +++

main関数内でのmemcmpの引数と返り値が取れており、今回の場合はAlpaca{に該当する部分まで0が返ってきているのが分かるはずです。これを用いてフラグを一文字ずつ確定させていくスクリプトを以下に示します。

import subprocess
from tqdm import tqdm

from extract_data import FLAG_LEN

def check(flag):
  res = subprocess.run(["ltrace", "./checker"], input=flag + "\n", capture_output=True, text=True).stderr
  iter = 0
  for l in res.splitlines():
    if not l.startswith("memcmp"): continue
    _, retval = l.split("=")
    if retval.strip() != "0":
      return iter
    iter += 1
  return iter

flag = ""
for i in tqdm(range(FLAG_LEN)):
  candidates = []
  for c in range(0x20, 0xff):
    if check(flag + chr(c)) == i + 1:
      candidates.append(chr(c))
  assert len(candidates) == 1, candidates
  flag += candidates[0]

print(flag)

今回の問題では、これが想定解でした*3。ltraceは、このように直接的に問題を解くため以外にもおおよそのバイナリの動作を把握するためにも用いることができます。-lオプションで呼ばれる関数のフィルターも可能ですし、意外と知られていませんが-xオプションを用いればバイナリ内の関数呼び出しもフックできるようになります。何をやっているか一目ではわからないバイナリでは、システムコールをフックするstraceやltraceを噛ませて動作を概観することを選択肢として持っておくとよいかもしれません。

LD_PRELOADを用いてmemcmpを差し替える

先程はmemcmpの呼び出しをフックしましたが、さらに一歩進んでmemcmpを自分で用意した関数にしてしまいましょう。今回の手法では、memcmpが動的リンクされていることを用います。動的リンクおよび動的リンカについてはここでは詳述しませんが、端的に言うならば実行ファイルの外にある関数を関数名から動的にロードする仕組みが動的リンクで、それを実現しているバイナリが動的リンカです。Linuxで用いられている動的リンカであるld.soにはリンクの詳細を制御する様々なオプションが存在していて、そのうちの一つであるLD_PRELOAD環境変数を今回用います。

LD_PRELOAD環境変数にshared objectファイルへのパスを渡すと、動的リンク時にshared object内に含まれる関数が他のどの関数よりも優先してリンクされるようになります。つまり、バイナリから呼んでいる外部の関数(ここではmemcmp)を自作の関数で上書きできるということです。

shared object自体はただのELFなので、通常のCのプログラムのように書くことができます。今回書いたmemcmpは、2つの引数が一致していた場合にputs("ok")を実行するようにしています。

#include <stdio.h>
int memcmp(char* buf1, char* buf2, int len) {
  for (int i = 0; i < len; i++) {
    if (buf1[i] < buf2[i]) return -1;
    if (buf1[i] > buf2[i]) return 1;
  }
  puts("ok");
  return 0;
}

これをshared objectとしてコンパイルには、-sharedオプションを付けるだけで問題ありません。-fPICオプション*4をつけている文献もありますが、2017年程からデフォルトでこのオプションが有効になっているため、2024年現在では不要だと認識しています*5。

このようにして作成したshared objectファイルをLD_PRELOADに指定すると、実装したmemcmp関数が呼び出されていることが分かります。

$ gcc -shared -o hook.so hook.c
$ echo Alpaca{fakeflag} | LD_PRELOAD=./hook.so ./checker
flag? ok
ok
ok
ok
ok
ok
ok
Wrong...

後は、ltraceの場合と同様に解くことができます。

import subprocess
from tqdm import tqdm
from extract_data import FLAG_LEN

def check(flag):
  res = subprocess.run(["./checker"], env={"LD_PRELOAD": "./hook.so"}, input=flag + "\n", capture_output=True, text=True).stdout
  return res.count("ok")

flag = ""
for i in tqdm(range(FLAG_LEN)):
  for c in range(0x20, 0xff):
    if check(flag + chr(c)) == i + 1:
      flag += chr(c)
      break

print(flag)

ltraceを用いた解法は低速でしたが、この解法は同様のことをやりつつ高速に動作します。これはltraceがデバッガのようにブレークポイント張ることで機能している一方、これはそういったことは行っていないためです*6。また、LD_PRELOADはデバッグしたいプロセスが既に別プロセスにトレースされているといった状況でも使用できることも魅力です。

$ time python solver_ld_preload.py
python solver_ld_preload.py  1.83s user 2.05s system 111% cpu 3.472 total
$ time python solver_ltrace.py
python solver_ltrace.py  15.22s user 38.00s system 102% cpu 51.907 total

memcmpをputsに置き換え、各ループでのhashを得る

前項では、動的リンカは関数名をもとにリンクしていると書きました。これはつまり、リンクに用いられている文字列を他の文字列に置き換えれば、別の関数がリンクされるようになるということです。このバイナリでは、memcmpはmemcmp(hash, table[i], 16)として呼ばれていました。よって、memcmpをputsに置き換えればputs(hash)が呼ばれていることになるため各ループでのhashを得ることができそうです。

今回はhashを抽出するため、一致判定もソルバで行う必要があります。そのため、tableのデータを抽出しないといけません。私は普段はGhidraに存在するCopy as 機能で抽出することが多いですが、今回は敢えてpwntoolsを用いて抽出してみることにします。

from more_itertools import chunked
from pwn import ELF

FLAG_LEN = 49
DIGEST_LEN = 16

def get_table():
  elf = ELF("./checker")
  res = elf.read(elf.symbols["table"], FLAG_LEN * DIGEST_LEN)
  return list(map(bytes, chunked(res, DIGEST_LEN)))

次に、checkerのmemcmpをputsに置き換えたバイナリを作成します。高度なツールを使わずにバイナリをパッチする際の原則ですが、オフセットやサイズを変更するとそれに対応する場所も編集しないといけなくなるため、データの置き換えは上書きで行うことが望ましいです。なお、後ほど紹介するLIEFのようなツールはオフセットの編集なども自動で行ってくれます。

import subprocess

res = open("checker", "rb").read()
open("checker_puts", "wb").write(res.replace(b'memcmp', b'puts\x00\x00'))
subprocess.run("chmod +x checker_puts", shell=True)

あとは、出力を基に一致判定をしていけばよいです。hashのうちNULLバイトの先が出力されないために複数候補が出る可能性を考慮し、バックトラックを行えるようにDFSとして実装しました。

import subprocess

table = get_table()

def check(flag: bytes):
  output = subprocess.run("./checker_puts", input=flag+b'\n', capture_output=True).stdout
  expected_hash = table[len(flag) - 1].split(b'\x00')[0]
  return expected_hash in output

def solve(flag):
  if len(flag) == FLAG_LEN:
    return flag
  for c in range(0x20, 0xff):
    next_flag = flag+bytes([c])
    if check(next_flag):
      res = solve(next_flag)
      if res is not None: return res
  return None

print(solve(b''))

今回は動的ライブラリの解決先を変更するという他の手法でも代替できることを行いましたが、このように理解が及ぶ部分をパッチして半ば強引にバイナリの動作を都合の良いように変更することは高難易度の問題でもたまにとられる手法です。

gdbでmemcmpの返り値を得る

今までの解法はバイナリからmemcmp関数が呼ばれていることに依存していましたが、もしも呼ばれていなかったらどのように解けばよいでしょうか。一つの答えとして、デバッガを用いた内部状態の取得があります。今回は、gdbを用いたデバッグを自動化してみます。

まず、プログラムを一時停止させるブレークポイントを張る場所を探します。今回はmemcmpの返り値がほしいので、memcmpが呼ばれた直後に張るとよさそうです。gdbを用いてアセンブリを見てみると、今回はmain+175が該当しそうです。

$ gdb ./checker
...
pwndbg> start
...
pwndbg> x/80xi $rip
=> 0x555555555980 <main>:       endbr64
   0x555555555984 <main+4>:     push   r13
   0x555555555986 <main+6>:     push   r12
   0x555555555988 <main+8>:     push   rbp
...
   0x555555555a1f <main+159>:   add    rsi,r13
   0x555555555a22 <main+162>:   mov    edx,0x10
   0x555555555a27 <main+167>:   mov    rdi,rbp
   0x555555555a2a <main+170>:   call   0x5555555550b0 <memcmp@plt>
   0x555555555a2f <main+175>:   test   eax,eax
   0x555555555a31 <main+177>:   sete   al
   0x555555555a34 <main+180>:   movzx  eax,al
...

あとは、ここにブレークポイントを貼った後にeaxを取得し、continueする流れを自動化すればよいです。自動化は、gdbが提供しているPythonのAPIを用います。このAPIは、gdbでのデバッグに慣れているならば非常に簡単に使えます。今回使うべきメソッドはgdb.executeでのコマンド実行と、gdb.parse_and_evalでの値取得の2つのみです。なお、メモリ内容やレジスタの内容を取得する専用の関数はあるものの、色々と使いにくいのでここでは使用していません。

以下は、これらを用いて書いたスクリプトです。このスクリプトは、gdbの-xオプションに渡してgdb -q -x solver.py のようにすることで実行することができます。

import gdb
from tqdm import tqdm

FLAG_LEN = 49

gdb.execute("file ./checker")

gdb.execute("start")
gdb.execute("b *main+175")
gdb.execute("det")

def check(flag):
  open("input.txt", "w").write(flag)
  gdb.execute("r < input.txt") # run

  res = 0
  for i in range(FLAG_LEN):
    eax = gdb.parse_and_eval("$eax")
    if eax == 0: res += 1
    gdb.execute("c") # continue

  return res

flag = ""
for i in tqdm(range(FLAG_LEN)):
  for c in range(0x20, 0xff):
    if check(flag + chr(c)) == i + 1:
      flag += chr(c)
      break

print(flag)

gdbを自動化してソルバを書くことは頻繁にはないですが、嵌まる場面ではとても便利な印象があります。しかし、汎用のデバッグツールを用いている都合上、実行速度がかなり遅いことが欠点として挙げられます。実際、今回のスクリプトは実行に30分程度かかりました。

updateを自由に実行できるバイナリを作る

今まではバイナリを実行した結果を何らかの形で取得していましたが、元のバイナリを介さない方法はないのでしょうか?ここからは、update関数を直接呼び出すことを目標としてみます。

手始めに、update関数を実行するためだけのバイナリを作成してみましょう。先程のようにmain関数を書き換えてもよいのですが、ここではもう少しシンプルな方法をとろうと思います。今回、update関数内に動作に必要な関数呼び出しは存在しません*7。そのため、update関数は直接別のところに持っていったとしても動きそうです。今回は、実行可能にしたstackにシェルコードのような形で関数のデータを置き、それを呼び出すという手法をとります。以下は、これを行うPythonスクリプトとコンパイルされるCのコードです。update関数の内容はマクロとしてコンパイル時に注入しており、-z stackオプションをつけることでstackを実行可能にしています。

import subprocess
from pwn import ELF

elf = ELF("./checker")
update = elf.read(elf.symbols["update"], 0x7a9)

subprocess.run(f"gcc -z execstack -DFUNC={','.join(map(str, update))} -o executor executor.c 2>/dev/null", shell=True)
#include<stdio.h>
int main(){
  char update[] = { FUNC };
  char buf[36];
  read(0, buf, 36);
  char c;
  read(0, &c, 1);
  ((void(*)(char*, char))update)(buf, c);
  write(1, buf, 36);
}

このようにして作られたバイナリを用いれば、main関数で行っていたhashの更新とmemcmpの処理を完全にPython側で行うことができるようになります。これを行うスクリプトは以下の通りです。

import subprocess
from tqdm import tqdm
from extract_data import FLAG_LEN, get_table
def compute(state: bytes, c: int):
  res = subprocess.run("./executor", input=state + bytes([c]), capture_output=True).stdout
  return res

table = get_table()

flag = ""
state = b"\x00" * 36
for i in tqdm(range(FLAG_LEN)):
  for c in range(0x20, 0xff):
    next_state = compute(state,c)
    if next_state[:16] == table[i]:
      flag += chr(c)
      state = next_state
      break

print(flag)

ctypes.CDLLを用いてPythonからupdateを実行する

Pythonには共有ライブラリを読み込んで、その関数をPythonから実行するためのctypesというライブラリが存在します。これを用いれば、前項のようなことをせずともPythonから直接update関数を呼び出すことが可能そうに思えます。しかし、update関数が存在するのは共有ライブラリではなく実行ファイルであるため、そのままでは読み込むことが不可能です*8。これは、LIEFというバイナリパッチのためのライブラリを用いることで解決できます。なお、これは公式ドキュメントでもチュートリアルの一貫としても紹介されていているユースケースです。

lief.re

import lief

bin = lief.parse("./checker")
bin.add_exported_function(bin.get_function_address("update"), "update")
bin[lief.ELF.DynamicEntry.TAG.FLAGS_1].remove(lief.ELF.DynamicEntryFlags.FLAG.PIE)
bin.write("checker.so")

このようにして変換したライブラリを用いれば、Python内でupdate関数を直接呼び出すことができます。ctypesでの関数の呼び出しは引数のラッパークラスが少々複雑なので、ドキュメントや先人のコードなどを見ながら書くことをおすすめします。

import subprocess
from tqdm import tqdm
from extract_data import FLAG_LEN, get_table

import ctypes

HASH_LEN = 32
lib = ctypes.CDLL('checker.so')

def update(state: bytes, c: int):
  state_array = ctypes.create_string_buffer(state)
  lib.update(state_array, ctypes.c_char(bytes([c])))
  return bytes(state_array)

table = get_table()

flag = ""
state = b"\x00" * 36
for i in tqdm(range(FLAG_LEN)):
  for c in range(0x20, 0xff):
    next_state = update(state, c)
    if next_state[:16] == table[i]:
      flag += chr(c)
      state = next_state
      break

print(flag)

ctypesはPythonのプロセス内に直接update関数をロードしているので、なんといっても圧倒的な速さが特徴です。これまでのソルバでは最速のものでも1秒程度はかかっていたものの、このソルバはバイナリを呼び出すオーバーヘッドが存在しないために一瞬でフラグが求まります。また、ctypesは標準ライブラリの関数を簡単に呼び出すことができるので、srand及びrandomをPythonから用いるのにもよく使用されます。

from ctypes import CDLL

libc = CDLL("libc.so.6")
libc.srand(0)

assert libc.rand() == 1804289383

また、今回はLIEFをライブラリへの変換のための用途にのみ用いましたが、LIEFはELFの情報の取得やライブラリ関数の処理の差し替えといった他の用途でも用いることができます。

angrとUnicornを用いてバイナリをエミュレートする

力尽きてきたので前段は軽めに書きます。いつか書き直すかも……

angrとは、シンボリック実行を行うライブラリです。シンボリック実行とは、入力値を変数(シンボル)として持っておき、メモリやレジスタの状態を変更しながら条件分岐のたびに状態を分岐させ、各状態に辿り着くための条件を集める実行方式のことです。例えば、以下のようなソースコードからコンパイルされたバイナリがあったとします。

int main() {
  int val=0;
  scanf("%d", &val);
  if (val * 0xdeadbeef == 0xcafebabe) {
    puts("OK!");
  }
}

これにangrを用いると、指定した場所に到達するのに必要な条件を自動で収集し、その上で必要な入力を自動で計算してくれます。

import subprocess
import angr

proj = angr.Project("angr_example")

state = proj.factory.entry_state()
simgr = proj.factory.simulation_manager(state)

simgr.explore(find=lambda state: b'OK!' in state.posix.dumps(1))
# simgr.explore(find=0x4011da) # can also be address
state = simgr.found[0]
print(state.posix.dumps(0))

一見すると全てのreversingを解くことができる最強のツールのように思えますが、そうではありません。angrは必要な入力を計算するためのバックエンドとしてSMTソルバを用いているため、求めることができる値はソルバの能力に縛られます。また、条件分岐とループが組み合わさるなどした場合は状態が分岐しすぎてしまい、angrでは処理しきれなくなってしまうこともあります。今回のupdate関数は条件分岐および非線形な処理を含んでおり、SMTソルバ及びangrが非常に苦手としているタイプのバイナリです。

ただ、angrはバイナリのエミュレートやその状態の複製を容易に行うためのインターフェイスが整えられています。また、シンボルがない状態であればUnicornというCPUのエミュレータを用いて高速なエミュレーションを行うことも可能です。これに注目し、今回はangrをUnicornのフロントエンドとして用いてみることにします。先程も述べたように、angrは複数の状態を並行して管理することができます。先程は状態の分岐は条件分岐時に行われると書きましたが、状態の分岐をこちらで行えば一文字ずつの探索を行えそうです。また、条件に到達する見込みがなくなったステートを判定して枝狩りすることで、不要な探索を防ぐこともできます。これを行うスクリプトは以下のとおりです。

import angr
import claripy

proj = angr.Project("checker")

AT_CHR_LOAD = 0x401a08
AFTER_MEMCMP = 0x401a2f

def step_func(lsm: angr.SimulationManager):
  def remove_non_zero(state: angr.SimState):
    if state.addr != AFTER_MEMCMP: return False
    return state.regs.rax.concrete_value != 0

  def populate_next(state: angr.SimState):
    if state.addr != AT_CHR_LOAD: return False
    print(state.globals["flag"])
    states = []
    for c in range(0x20, 0x80):
      new_state = state.copy()
      new_state.globals["flag"] += chr(c)
      next_flag = new_state.globals["flag"].encode()
      new_state.memory.store(state.regs.sp + 0x30, next_flag, len(next_flag))
      states.append(new_state)
    return states

  lsm.drop(filter_func=remove_non_zero)
  lsm.apply(populate_next)
  
  return lsm

state = proj.factory.entry_state(
  stdin=claripy.BVV(b''),
  add_options={
    *angr.options.unicorn,
    "ZERO_FILL_UNCONSTRAINED_MEMORY",
    "ZERO_FILL_UNCONSTRAINED_REGISTERS",
  }
)
state.globals["flag"] = ""

# unicornでの実行はクールダウンがあるので、それを無効化する
state.unicorn.cooldown_nonunicorn_blocks = 0

simgr = proj.factory.simulation_manager(state)
simgr.explore(
  step_func=step_func,
  find=lambda state: b'Correct!' in state.posix.dumps(1),
  extra_stop_points={ AT_CHR_LOAD, AFTER_MEMCMP }
)

print(simgr.found[0].globals["flag"])

正直に言えばangrをここまで突っ込んだ使い方をしたのは初めてだったのですが、思ったよりも疲れました。このような問題をangrで解くことはお勧めしません。とはいえ、特定のアドレスの動作をフックしたり、特定の関数だけ実行したりとただ殴る以上の使い道があるツールでもあります。もしも使いこなせれば、強力な道具となるでしょう。

おわりに

Reversingはただの腕力ジャンルだと思われがちですが、私は目の前の課題に対してどの側面から攻めるかを適切に選択することが大切なジャンルだと考えています。本Writeupでも、同じ目標に対する様々な手法を紹介してきました。今回の問題では遠回りなものでも、他の問題では逆にクリティカルに用いることができるものも存在しています。他にも、「面倒なpycの問題がgdbを用いてattachp pythonとsearch flag{をすることで解けた」、「独自アーキテクチャのエミュレータに内部状態をダンプするコードを加えて自前でコンパイルしたら見通しが良くなった」といったケースも分かりやすい一例でしょうか。

とはいえ、今回のように動的解析をすることが明確によい場合もあれば、処理を読みたくないので凝りに凝った動的解析をした結果、解けはしたものの結局静的に処理を読んだ方が早かった……といったこともしばしばあります。ここのよい側面を見分ける嗅覚*9はやはり経験で鍛えられていくものだろうと感じています。

一方で、側面や攻め方の引き出しは数をこなしても増えていきますが、エミュレータやインタプリタのコードリーディング、OSといった一つレイヤの低い部分の仕組みについて造詣を深めることでも増えていきます。最近刊行されたBinary Hacks Rebootedおよび前作(?)のBinary Hacksは、そういった知識を増やしたい人にはお勧めの一冊なのではないでしょうか。今回紹介した解法に用いられているltraceやLD_PRELOADについてはBinary Hacksに掲載されていますし、gdbやangr、LIEFについてはBinary Hacks Rebootedに掲載されています*10。

なんだか最後はステマじみてしまいましたが*11、ここまで読んでいただきありがとうございました!この記事がよいCTFライフをお過ごしいただく一助になれば幸いです。

*1:問題名はRound 2で出題されたSimple Loginのセルフオマージュです。

*2:色々検証しましたが、実際のところとして満たしているかは怪しいです。

*3:main関数だけ最適化レベルを下げることでmemcmpを関数のまま残し、この手法が適用できるようにしています。

*4:このオプションは、配置されるアドレスが不定でも動作する位置独立コードと呼ばれるバイナリを生成するオプションです。

*5:32bitアーキテクチャをターゲットにした場合も有効になっているかなどは分からないです。

*6:とはいえ、どうしてltraceがここまで低速なのかは謎です。もう少し早くてもよい気もしますが……

*7:__stack_chk_failは存在しますが、これは正常系ならば呼ばれることのない関数なので無視してよいです

*8:shared objectを開くための関数であるdlopenでは実行ファイルを開くことができません。また、update関数はexportされていないため、開けたとしても関数を取得することができません

*9:私はA*アルゴリズムの評価関数のようなイメージを持っています

*10:当然ですが、この問題はこれらの本を読んで作問したというわけではありません。それだけ広く適用できるテクニックをカバーしているということでしょう

*11:本当に違います