前言

这篇文章是,neise1群聊里的paradox师傅写的,我觉得不错的,在师傅的强烈要求下,neise1就把这篇文章发在了本人的博客上,额,切记博客的封面是作者本人指定的折木奉太郎,我拼尽全力才挑出来一个不糖的,已燃尽

格式化字符串漏洞偏移

——在printf机制下的理解

参数传递规则:在 x86 (32位) 中,所有参数都压入栈 (Stack)

​ (64位下)前 6 个参数通过寄存器(rdi, rsi, rdx, rcx, r8, r9)传递,剩下的才压入栈

参数是如何传递的:

32位下,依照 从右向左 的压入规则,

printf(“%d %x”, a, b)

如上述代码,当执行此段代码时 会将 b,a,指向”%d %x”的地址依次压入栈中

​ 内存地址 存储 内容备注

高 0xffffff10 0x00000002 参数 b

​ 0xffffff0c 0x00000001 参数 a

​ 0xffffff08 0x08048000 format 指针 eg:指向”%d %x”

低 0xffffff04 [返回地址] call 指令自动压入

64位下因为有6 个参数寄存器的缘故

在第一阶段采用从左向右的压入规则

rdi:存放第一个参数(format 字符串的地址)eg:指向”%d %x”

rsi:存放第二个参数(第一个% 对应的值)eg:a

rdx:存放第三个参数。

rcx:存放第四个参数。

r8:存放第五个参数。

r9:存放第六个参数

当寄存器全部用完之后进入第二阶段压入栈(从右向左)

eg:

假设你的调用是:printf("%d %d %d %d %d %d %d", 1, 2, 3, 4, 5, 6, 7);

在执行 call printf 的那一刻,CPU 的状态是这样的:

  • 寄存器:

    • rdi: 指向 "%d...%d" 的内存地址

    • rsi: 1

    • rdx: 2

    • rcx: 3

    • r8 : 4

    • r9 : 5

  • 栈顶 (rsp):

    • [rsp]: 6 (这是第 7 个参数)

    • [rsp+8]: 7 (这是第 8 个参数)

压入参数之后就是调用

内部指针初始化 —逐字符扫描—寻址取值—指针偏移

内部指针初始化

调用 va_start(args, format)其中args format均为指针

32位原理: 此时内部指针 args 直接指向栈上 format 指针正上方(高地址)的第一个参数。

64位原理: 此时 args 会被初始化为一个结构体,里面记录了寄存器保存区(Register Save Area)的起始地址

总之就是指向第一个参数

逐字符扫描

扫描的主体是format的内容

遇到普通字符: 直接调用 putchar 或类似的系统调用输出到 stdout。

遇到% 字符: 进入“格式解析模式”。它会查看 % 后面的字符(如 d, x, s, n),来决定接下来要读取多少字节的数据,以及如何解释这些数据

寻址取值

动作: 指针argv根据偏移去指向特定的参数,然后进行取值

取值逻辑:

  • 如果是 %d:从指针当前位置取出 4 字节,解释为整数。

  • 如果是 %s:从指针当前位置取出 4 字节(或 8 字节)作为内存地址,然后跳到那个地址去读字符串直到遇到 \x00

指针偏:移

取完一个值后,指针必须移动,否则下一个占位符就会读到重复的数据。

  • 动作: 调用 va_arg(args, type),对其偏移进行修改

  • 关键特性: 这种移动是单向、不可逆

打印

欲要更为深层的理解请看printf源码

1. 第一层:printf 的入口 (vfprintf.c)

在 glibc 中,printf 实际上是 vfprintf 的一个包装。

1
2
3
4
5
6
7
8
9
10
// 简化后的 glibc/stdio-common/printf.c
int __printf (const char *format, ...)
{
va_list arg;
int done;
va_start (arg, format); // 1. 初始化内部指针 ap (在这里叫 arg)
done = vfprintf (stdout, format, arg); // 2. 核心逻辑全部在 vfprintf
va_end (arg);
return done;
}}

2. 第二层:核心调度中心 (vfprintf_internal)

这是 printf 最庞大的部分(通常在 glibc/stdio-common/vfprintf-internal.c)。它包含一个巨大的 while 循环和 switch-case 语句,负责逐字符扫描

寻址取值的宏:process_arg

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
`printf` 内部通过 `va_arg` 宏从 `va_list` 中取值。C// 模拟源码中的逻辑

while (*f != '\0')
{
if (*f != '%') {
outchar (*f++); // 普通字符直接输出
continue;

}
// 遇到 %,开始解析
f++;
// 解析格式说明符 (简化版)
switch (*f)
{
case 'd':
// 3. 寻址取值:依靠 va_arg 宏
v_int = va_arg (ap, int);
// 指针偏移:va_arg 内部会自动根据 sizeof(int) 移动 ap 指针
print_number (v_int);
break;

case 's':
v_str = va_arg (ap, char *); // 取出一个指针(二次寻址)
print_string (v_str);
break;

case 'n':
// 关键:%n 的实现
int *p = va_arg (ap, int *); // 寻址取值:取出一个内存地址
*p = done; // 写入:将已打印字符数写入该地址
break;
}

}}


3. 第三层:底层寻址 va_arg (汇编实现)

你关心的“依靠内部指针寻址”到底是怎么实现的?这其实是由编译器(如 GCC)实现的内部宏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
以 **x86_64** 为例,当调用 `va_arg(ap, type)` 时,底层的逻辑(伪代码)如下:// 64位 va_list 的结构

typedef struct {
unsigned int gp_offset; // 通用寄存器偏移 (0-48)
void *overflow_arg_area; // 栈地址(如果寄存器用完了就看这)
void *reg_save_area; // 寄存器备份区的首地址
// ...
} va_list[1];
// va_arg(ap, int) 的逻辑
if (ap->gp_offset < 48) {
// 1. 寻址:从寄存器备份区取值
void *addr = ap->reg_save_area + ap->gp_offset;
// 2. 指针偏移:移动 8 字节(64位下寄存器位宽)
ap->gp_offset += 8;
return *(int *)addr;
} else {
// 3. 寻址:从栈(溢出区)取值
void *addr = ap->overflow_arg_area;
// 4. 指针偏移:根据类型大小移动栈指针
ap->overflow_arg_area += 8;
return *(int *)addr;
}

GNU c-libc 镜像库

pwn中的漏洞利用

但是当我们给到的格式化字符串的数量大于参数的数量(或者没有参数)时,printf并不会检查到这个错误,他会读取相应偏移位置上原本存储的东西,这就出现了最基础的格式化字符串的漏洞(值泄露 这就可以达成地址,canary等的泄露)

通过 gdb更为直接的解释一下

32位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
char buf[100]; // [esp+8h] [ebp-70h] BYREF
unsigned int v4; // [esp+6Ch] [ebp-Ch]
v4 = __readgsdword(0x14u);

setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);

puts("Do you know repeater?");

while ( 1 )
{
read(0, buf, 0x64u);
printf(buf);
putchar(10);
}
}

我们将断点打在printf上图片!

停在此处 查看stack图片)图片)

这就可以找到format的偏移 为6,栈上的格式字符串漏洞有些可以直接通过第二张图片的方式泄露偏移,非栈上的就得gdb调试看了(以我目前的水平)

非栈上的也没必要找format的偏移了

64位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)

{

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

setvbuf(_bss_start, 0LL, 2, 0LL);

setvbuf(stderr, 0LL, 2, 0LL);

puts("Please checkin first");

read(0, buf, 0x100uLL);

printf(buf);

_exit(0);

}}图片
图片)图片
(注:黄色的为栈地址,会在每一次启动改变)

综上就是我的一些理解,在ai的帮助与我的努力下完成了这篇文章,希望可以给大家带来帮助

–作者:paradox