はじめに

 今年のセキュリティ・キャンプでは,うっかり「なぜマルウェア解析は自動化できないのか」という題の講義を行ってしまったが,それだけセキュリティの世界には自動化の波が来ている.本稿では,脆弱性分析の自動化をめざして開発されているangr, AFL, Drillerをざっくり紹介する.

angr

 angrはUCSBの研究チームにしてCTFチームShellphishを中心に開発されているバイナリ解析フレームワーク.論文[PDF]はIEEE S&P 2016に採択されている.手法の新規性というよりは実装力でゴリ押しするタイプ.評価には,アメリカ国防高等研究計画局が5,500万ドル(約56億円)の資金を投じてまで開催した脆弱性分析・修正の自動化コンペ,DARPA Cyber Grand Challenge (CGC) のデータセットが用いられている.CGCの決勝戦に進出したチームには75万ドル(約7,600万円),優勝したチームは200万ドル(約2億円)が与えられる.angr開発の目的のひとつが,CGCでの勝利にあることは疑いようもない——最終的な戦績は,CMUのツールMAYHEMに優勝を譲って3位だったが.
 さて,angrはシンボリック実行やプログラムスライシングを通して,プログラムの特定位置に到達するための入力値を抽出することができる.次のコードで雰囲気をつかめるだろうか:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env python
#-*- coding:utf-8 -*-
import sys
import angr
import simuvex
# 解析対象を指定
p = angr.Project(sys.argv[1])
# 制御フローグラフの生成
cfg = p.analyses.CFG()
print [x for x in cfg.functions.iteritems()]
# シンボルを取得
target_addr = p.loader.main_bin.get_symbol("main").addr
# パス分析クラスのインスタンス
pg = p.factory.path_group()
# シンボルへのパスを分析
pg.explore(find = target_addr)
# avoidを避け,findに到達する入力値を探索してくれる
a = p.surveyors.Explorer(find = FIND_ADDR, avoid = AVOID_ADDR).run()
# フック
p.hook_symbol('strlen', simuvex.SimProcedures['stubs']['ReturnUnconstrained'])
# 実行結果のダンプ
a.found[0].state.posix.dumps(1)

 解析対象の規模が大きい場合,エントリポイントを起点とした解析に時間を要するあるいは失敗することがあるが,プログラムの途中から解析を始めることも可能だ.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
p = angr.Project(sys.argv[1])
# 解析の起点となるアドレス
state = p.factory.blank_state(addr = START_ADDR)
# その地点までプログラムを実行したときのスタックの状態
state.regs.ebp = BASE_ADDR
state.regs.esp = STACK_ADDR
# 入力値の設定
for i in range(INPUT_LENGTH):
s = state.se.BVS('Var[{}]'.format(i), 32, explicit_name = True)
state.memory.store(INPUT_ADDR + i * 4, s)
path = p.factory.path(state)
a = p.surveyors.Explorer(start = path, find= FIND_ADDR, avoid= AVOID_ADDR)
a.found[0].state.posix.dumps(1)

 シンボリック実行はSSA形式の中間表現を前提とするが,angrはValgrindのVEX IRを用いている.バックエンドのSMTソルバはZ3だが,claripyという自前のラッパが噛ませてある.
 これ以上の説明は公式ドキュメントに譲ろう.

AFL

 AFL (American Fuzzy Lop) はGoogleのエンジニアlcamtufを中心に開発されているファジングツール.遺伝的アルゴリズムによって正常な入力を次々と変異させていき,自動的にプログラムのバグを検出する.AFLにはbashやtcpdump, OpenSSHといったさまざまなソフトウェアのバグを検出した実績があり,いまや脆弱性分析の研究になくてはならない存在だ.一般的なファジングはプログラムの浅い箇所にしか到達できない.AFLは,大量の正常な入力をトリミングしてシードとするコーパス蒸留 (corpus distillation) と,遺伝的アルゴリズムを用いてシードを変異させるGAFuzzingのいいとこ取りを図ったものだ.その実行フローは次のようになる:

  1. ユーザーから与えられた初期テストケースをキューに入れる
  2. キューから次の入力テストケースをひとつ取り出し,
  3. プログラムの振る舞いに影響を与えない最小サイズにトリミングする
  4. バランスのよい探索となるよう,さまざまな変異戦略を用いて入力を繰り返し変異させる
  5. 新たな状態遷移が計測されたら,出力をキューに入れる
  6. 2に戻る

 ここでAFLはカバレッジ計測のため,解析対象のプログラムに計測用のコードを埋め込む.これには,解析対象のソースコードが手元にある場合gccやclangの独自フロントエンドが,解析対象のソースコードが手元にない場合QEMUが用いられる.
 ファジング機能の中核は,afl-fuzz.cmain()から呼び出されるfuzz_one()にある.実装されている変異戦略は次の通り:

  • SIMPLE BITFLIP
  • ARITHMETIC INC/DEC
  • INTERESTING VALUES
  • DICTIONARY STUFF
  • RANDOM HAVOC
  • SPLICING

 CGCで優勝したCMUのMAYHEMは,このAFLにシンボリック実行機能を追加していたようだ.MAYHEMといえば同名のシンボリック実行エンジンの論文[PDF]がIEEE S&P 2012で発表されているが,当時からの変更点がどれほどなのかはわからない.
 また,AFLの拡張に,解析対象のパスを枝刈りすることで高速化を図ったAFLFastがある.これもCGC決勝進出チームによるもので,論文[PDF]はACM CCS 2016に採択されている.600行程度のパッチでトップカンファレンス.ちょっと妬ましい.

Driller

 Drillerはangrの開発陣によるAFLの拡張.論文[PDF]はNDSS 2016に採択されている.AFLのREADMEには次のようにある:

Other, more sophisticated research has focused on techniques such as program flow analysis (“concolic execution”), symbolic execution, or static analysis. All these methods are extremely promising in experimental settings, but tend to suffer from reliability and performance problems in practical uses - and currently do not offer a viable alternative to “dumb” fuzzing techniques.

 シンボリック(コンコリック)実行は実用的ではないと切って捨てている.DrillerはangrとAFLを組み合わせることで,この批判を克服し,ファジングとシンボリック実行のいいとこ取りを図ったものだ:

 たとえば,次のコードの解析には,ファジングよりシンボリック実行が適している:

1
2
3
4
5
6
7
int main(void)
{
int x;
read(0, &x, sizeof(x));
if(x == 0x123ABCD)
vulnerable();
}

 なぜなら,ファジングによって0x123ABCDという値を生成するには大量の試行を必要とするからだ.一方で,次のコードの解析には,シンボリック実行よりファジングが適している:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int check(char *x, int depth)
{
if(depth >= 100){
return 0;
}else{
int count = (*x == 'B') ? 1 : 0;
count += check(x+1, depth+1);
return count;
}
}
int main(void)
{
char x[100];
read(0, x, 100);
if(check(x, 0) == 25)
vulnerable();
}

 なぜなら,単純なシンボリック実行ではパス爆発を解決できないからだ.
 Drillerは両手法の長所と短所を踏まえた上で,それらを堅実に組み合わせている.やはりハッシュ関数の充足はDrillerをもってしても難しいようだが,現行MAYHEMもAFLとシンボリック実行を併用しているようだし,このアプローチが現在の着地点なのだろう.

おわりに

 CGCとそれにともなう研究によって,脆弱性分析の自動化手法は大きな進歩を遂げつつあることが伝わっただろうか.学会のOSS重視傾向も相まって,さまざまなツールを手元で試せるようになってきている.大変ありがたいことだ.

__attribute__((constructor))

main()が呼ばれる前に関数を呼ぶ方法として,一般にgcc拡張である__attribute__((constructor))が利用されている.
例えば次のコードでは,main()の前にconstructor()が呼び出される.

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
__attribute__((constructor)) void constructor(){
printf("constructor\n");
}
int main(){
printf("main\n");
return 0;
}

__libc_start_main()

gccが内部で呼び出すldは,glibcの初期化を行うためにcrt*.oを静的リンクする.これによって,_start()などのシンボルがバイナリに埋め込まれることになる.
ここで埋め込まれるシンボルには未定義なものが存在する.その一つとして,crt1.oに含まれる__libc_start_main()が挙げられる.
これはmain()より先んじて呼び出される関数であり,暗黙的にcrt1.oがリンクの対象となる.しかし未定義であるがゆえに,先にソースコードに__libc_start_main()が宣言されていた場合,ldはこちらをシンボルとして採用してしまう.

どちらが先か

上記を踏まえて,次のコードではどちらの関数が先に呼び出されるだろうか.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
__attribute__((constructor)) void constructor(){
printf("constructor\n");
}
int __libc_start_main(){
printf("__libc_start_main\n");
return 0;
}
int main(){
printf("main\n");
return 0;
}

正解は__libc_start_main()だ.私は実行するまで分からなかった.

__do_global_ctors_aux()

なぜ__libc_start_main()__attribute__((constructor))よりも先に呼び出されるのか.それは__attribute__((constructor))が,__do_global_ctors_aux()によって実現されているからだ.
__do_global_ctors_aux()_start(), __libc_start_main(), __libc_csu_init(), _init()を通ってようやく呼び出される.そのため,__libc_start_main()の方がより先んじて呼び出されるのだ.

__libc_csu_init()

Sigreturn-oriented Programmingでにわかに脚光を浴びている__libc_csu_init()__libc_start_main()の引数にセットされるコンストラクタだが,シンボルを上書きして__libc_start_main()の代わりにこちらをmain()ないし__attribute__((constructor))の先に呼び出すこともできる.
当初私は__attribute__((constructor))はこの辺りから呼び出されるものだと誤解していた.

Windowsでは

今回は触れないが,.CRT$X??のセクションやThread Local Storageのコールバックによって同様の機能を実現できる.

参考文献

はじめに

2014.12.07~08にわたって開催されたSECCON CTF 2014の予選(英語版)にて,ROP: Impossibleというpwn問題が出題された.タイトルの通り,この問題ではROPが制限されている.
ここでは,その実現手法として用いられていたIntel Pin(pintool)について,またこの問題にあった欠陥について述べる.
したがって,このエントリはROP: Impossibleのネタバレを兼ねている.注意されたし.

問題の概要

問題文は以下の通り.Pinによって保護された脆弱なバイナリからフラグを読み出せというものだ.

ropi.pwn.seccon.jp:10000
read /flag and write the content to stdout, such as the following pseudo code.
open("/flag", 0);
read(3, buf, 32);
write(1, buf, 32);
Notice that the vuln executable is protected by an Intel Pin tool, the source code of which is norop.cpp.

パッと見,バイナリは良心的な構成であるように思われる.

1
vuln: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, for GNU/Linux 2.6.26, BuildID[sha1]=0xcb671b1dc0409082c3f3962818d366fcb8771ead, not stripped

有効になっているのはNXだけだ.

1
2
RELRO STACK CANARY NX PIE RPATH RUNPATH FILE
No RELRO No canary found NX enabled No PIE No RPATH No RUNPATH vuln

だからといって,解法が自明であるわけではない.この問題の肝は,いかにしてPinによる保護をかい潜るかにある.

Pin

Pinは,Intelによって開発されたDBI(dynamic binary instrumentation)フレームワークである.
DBIとは,プログラムにコードを挿入することで実行時の情報を取得・操作する技術であり,プログラムのパフォーマンス測定やエラー検出,CPUキャッシュの分析や未定義命令のエミュレーションなど,多方面で応用されている.
さて,ROP: Impossibleは以下のコードによって保護されている.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <stdlib.h>
#include "pin.H"
ADDRINT shadow_stack[4096];
int shadow_sp = -1;
VOID push_retaddr(ADDRINT esp, ADDRINT eip)
{
if(shadow_sp >= (int)sizeof(shadow_stack) - 1){
// cannot push retaddr to shadow stack
exit(-1);
}
PIN_SafeCopy(&shadow_stack[++shadow_sp], (VOID*)esp, sizeof(ADDRINT));
}
VOID pop_retaddr(ADDRINT esp, ADDRINT eip)
{
ADDRINT retaddr;
PIN_SafeCopy(&retaddr, (VOID*)esp, sizeof(ADDRINT));
while(shadow_sp >= 0 && shadow_stack[shadow_sp--] != retaddr);
if(shadow_sp < 0){
exit(-1);
}
}
VOID check_syscall(ADDRINT eax)
{
switch(eax){
// syscalls for exploit
case 3: // sys_read
case 4: // sys_write
case 5: // sys_open
case 6: // sys_close
// syscalls executed until entry point
case 45: // sys_brk
case 122: // sys_newuname
case 192: // sys_mmap2
case 197: // sys_fstatfs64
case 243: // sys_set_thread_area
break;
// invalid syscalls
default:
exit(-1);
}
}
VOID insert_hooks(INS ins, VOID *val)
{
if(INS_IsCall(ins)){
// push retaddr to shadow stack
if(XED_ICLASS_CALL_FAR == INS_Opcode(ins)){
exit(-1);
}
INS_InsertCall(ins, IPOINT_TAKEN_BRANCH,(AFUNPTR)push_retaddr,
IARG_REG_VALUE, REG_ESP, IARG_INST_PTR, IARG_END);
}else if(INS_IsRet(ins)){
// pop retaddr from shadow stack, and then check it
if(XED_ICLASS_RET_FAR == INS_Opcode(ins)){
exit(-1);
}else{
INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)pop_retaddr,
IARG_REG_VALUE, REG_ESP, IARG_INST_PTR, IARG_END);
}
}else if(INS_IsSyscall(ins)){
// check syscall
INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)check_syscall,
IARG_REG_VALUE, REG_EAX, IARG_END);
}
}
int main(int argc, char *argv[])
{
PIN_Init(argc, argv);
INS_AddInstrumentFunction(insert_hooks, NULL);
PIN_StartProgram();
return 0;
}

このプログラムが,vulnの共有ライブラリとして噛まされている.INS_*は命令単位のinstumentationのために提供されているPinのAPIである.
要するに,リターンアドレスの検証によって,ROPが制限されているのだ.

JOP

ROPができない環境ならば,どうすればよいのか.
そもそも,ROPに用いられるretは,スタックの最上位アドレスに対するpopjmpと等価であると見做せる.ゆえにjmpによってchainを構築すれば,retを用いること無くROPと同様なコードを作成することができる.これをJOP(Jump-oriented programming)という.
さらに,スタックに対するアドレスのpushjmpは,callと等価であると見做せる.ゆえにcallpopによってROPを代替することができる.
つまり,ROP: Impossibleはret制限下の環境を前提にjmpcallでchainを構築しろというストイックな問題だった.
Pinに起因する欠陥がなければ.

writeup

この問題を最初に解いたのは,TOEFL BEGINNERという謎のチームだった.続いてbinja, PPPと続いている.
優勝チームであるPPPメンバーのRicky Zhouが公開しているwriteupを見てみよう.

明らかにおかしい.bssセグメントにシェルコードを置いて実行しているだけではないか.
だが,このバイナリはNXが有効だったはずだ.ローカルでこのコードを実行しても,SIGSEGVが発生する.
これは一体どうしたことだろう.

JITコンパイルの弊害

結論から言うと,Pinの制御下にあるバイナリのNXは無効化されるようになっている.
/proc/pid/mapsを確認すると,一見nonexecであるように見える.

1
bf984000-bf9a5000 rw-p 00000000 00:00 0 [stack]

だが,実際はそうではない.
PinはJITコンパイルによってinstrumentationを実現している.そのため,バイナリはPinによってmmapされ,execされる.このとき,Pinは元来バイナリに付与されていた実行権限を無視してしまう.
つまり,ROPを制限するためのPinが,NXを無効化してしまっていたのだ.
そもそも,LinuxにおいてNXの状態をmaps以外から取得するのは難しい.強いて挙げるならば,checksecのようにreadelf -W -l file | grep 'GNU_STACK'を叩くといったところだろうか.だが,これだけではmmapやmprotectに追随することができない.WindowsのVirtualQueryに相当する機能はないのだろうか.

おわりに

XSS Bonsaiも同様だが,CTFについてもテストは重要であるということを気付かされた.
運営の穴を突くのもCTFの醍醐味のひとつなのだろうが,しかし厄介な問題である.
最近はPinやDynamoRioによるマルウェア解析が流行っているように見受けられるが,やがてはDBIツールのデメリットについても検討を加えなければならなくなるだろう.

はじめに

Return-oriented programming(ROP)が提唱されて久しい.CTFにおいても,ROPは当たり前のように要求される技術となってきている.一方で,ROPに代わる新たな攻撃手法も模索されている.ここでは,そういったROP以後の攻撃手法を概観する.

Return-oriented programming

コンセプト

まずは簡単にROPをおさらいする.
ROPは主にハードウェアDEPという脆弱性対策技術に対抗するために編み出された攻撃手法である.ハードウェアDEPはCPUのNX bitを有効化することで,スタック上でコードを実行する攻撃を無効化する.
そこで,ROPはコード領域のretで終わる命令列(gadget)を実行することによってこれを回避する.単体のgadgetではわずかな処理しか行えないが,スタックに次のgadgetのアドレスを積むことで,gadgetを組み合わせたchainを構築することができる.ROPの作成にはコード領域からgadgetを探索する段階とgadgetを組み合わせる段階を要する.レジスタの操作はpopによって行う.

ASLR/PIEの迂回

ASLRはスタックやヒープなどが読み込まれる位置を実行ごとにランダマイズすることで,アドレスを決め打ちした攻撃を困難にする.だが,ASLRはROPに対処しうる技術ではない.
まず,LinuxにおいてASLRはコード領域のランダマイズを行わないため,スタックオーバーフローなどをROPの起点とすることができる.また,bssセグメントに存在する変数のアドレスを取得することができるのは,ASLRの重大な欠陥である.さらに,32bitの場合は総当りによってアドレスを特定できるほか,ulimit -s unlimitedによってASLRを無効化することができる.
PIEはASLRに加えてgadgetのアドレスまでランダマイズの対象となるため,攻撃にあたってはleakが前提となる.

RELROの迂回

RELROはセクションをread onlyに設定する機能である.Partial RELROとFull RELROがあり,前者の場合はGOTを利用した攻撃が可能である.後者の場合はread onlyにしうる全てのセクションがread onlyとなるが,ROPによって攻撃を成立させることができる.

Stager

攻撃に使える領域のサイズが制限されている場合,readなどの関数を用いて再度メモリに書き込む方法をstagerと呼ぶ.

Stack Pivot

スタックのサイズ上,リターンアドレスの下にROP chainを構築できないような場合,xchg esp,eaxなどのgadgetを用いてスタックのアドレスを移動させる方法をstack pivotと呼ぶ.

論文

BROP: Blind return-oriented programming

コンセプト

以下の要件を満たすサーバープログラムの場合は,バイナリが手元になくてもROPを試みることができる.

  • listen,forkを行う
  • スタックオーバーフローで子プロセスが落ちた場合も動き続ける
  • GOTにstrcmpなど第三引数を操作できる関数が存在する
  • GOTにwriteなどleakさせることができる関数が存在する
  • 総当りなどでGOTのアドレスが分かる

目的は,自身をwriteによってダンプさせることである.
まず,リターンアドレスを総当りして,無限ループに陥るようなgadget(STOP gadgetと呼称)を探す.次に,関数にinnvalidな引数を渡した場合であっても,GOTに正しく飛んだ場合はSEGVしないという点に着目し,0x08040000周辺に存在しているはずのGOTセクションの位置を推定する.推定にあたっては,+0x6のアドレスや次のGOTエントリに飛んだ場合もSEGVしないという特性を用いる.そして,fdを総当りするか,多数のコネクションからwriteを探り当てる.
GOTエントリは要素数のサイズが同じであるため,スタックのleakからゴリ押しできる,といった内容のようだが,実際のところどうなのだろうか.CTFのネタとしては面白いのではないか.

論文

実例

SROP: Sigreturn-oriented Programming

コンセプト

vdsoには,シグナル割り込みから復帰する際に,ユーザーランドのスタック上に作成したsignal frameに保存している値を全てのレジスタへ戻すsigreturnという命令が存在する.つまり,popadが廃止されたx64においても,sigreturnによってスタック上の値を複数のレジスタにセットすることができる.これによって,任意のシステムコールを呼び出すことが可能となるほか,関数の呼び出しがレジスタ渡しの場合においてもROPが容易になる.なお,vsyscallはASLRが有効であっても固定アドレスである.
ulimit -s unlimitedを用いてvdsoのマッピングアドレスを固定できる場合はCTFでも活用できそうだ.

論文

実例

JOP: Jump-oriented programmingとCOP: Call-oriented programming

コンセプト

通常,retの次にはそのサブルーチンを呼び出したcallの次の命令が存在する.そこで,コールスタックを辿ることでROPによってretが使われていないか検出するROPguardが考案された.ROPguardはMicrosoftの脆弱性対策ツールであるEMET 3.5の根幹を成す理論だった.
そこで,retの代わりにjmpを用いるJump-oriented programmingが考案された.また,retやjmpの代わりにcallを用いるCall-oriented programmingも可能である.例えば以下のコードスニペットにおいて,callはjmpと実質的に等価である.

pop esi;
ret;
push eax;
call esi;

; call先
pop esi ;retアドレスを除去
;eaxを用いる処理

COPでは,pushのような表現力の高い命令を用いることができる.

論文

ROPguard

JOP

COP

実例

COP

おわりに

ざっとROP以後の攻撃手法を列挙した.これ以外にも,AlphanumericなROPを作成する技術やバイナリから自動的にROPを作成する技術などが研究されている.また,roputilsBAPなど,ROPを支援するフレームワークも開発されている.これらについてもいずれ理解したい.