安全编码实践:缓冲区溢出防护、整数溢出检查、格式化字符串安全
说实话,安全编码这块,我以前也吃过亏。刚入行那会儿,总觉得「功能跑通就行」,什么缓冲区溢出、整数溢出,都是理论课上的东西。直到有一次,我负责的一个网络模块在压力测试下直接崩溃,排查了一整天才发现——嗯,就是一个 sprintf 没控制长度。从那以后,我再也不敢小看这些「小细节」了。
今天咱们就聊聊三个最常见的坑:缓冲区溢出、整数溢出、格式化字符串漏洞。每个我都会结合自己的踩坑经历来讲,希望能帮你少走弯路。
一、缓冲区溢出:最经典的漏洞
缓冲区溢出,说白了就是往一个固定大小的数组里写了超出容量的数据。多余的数据会覆盖相邻内存,轻则程序崩溃,重则被攻击者利用执行恶意代码。
1.1 危险函数一览
我个人习惯,凡是看到下面这些函数,都会多留个心眼:
| 危险函数 | 问题 | 安全替代 |
|---|---|---|
| gets() | 不检查输入长度 | fgets() |
| strcpy() | 不检查目标缓冲区大小 | strncpy() 或 strlcpy() |
| strcat() | 不检查目标缓冲区剩余空间 | strncat() |
| sprintf() | 不检查输出长度 | snprintf() |
| scanf("%s") | 不限制输入长度 | scanf("%Ns") 或 fgets() |
1.2 实战案例:从崩溃到修复
我在项目中遇到过这样一个场景:一个日志模块用 sprintf 拼接字符串,结果日志内容一长,直接崩了。当时排查的代码大概是这样的:
// ❌ 危险写法
char log_buf[256];
sprintf(log_buf, "User: %s, Action: %s, Detail: %s",
username, action, detail);
write_log(log_buf);
你想想看,如果 detail 字段是用户输入的,长度完全不可控。一旦超过 256 字节,栈上其他数据就被覆盖了。
修复方案很简单:
// ✅ 安全写法
char log_buf[256];
snprintf(log_buf, sizeof(log_buf),
"User: %s, Action: %s, Detail: %s",
username, action, detail);
write_log(log_buf);
二、整数溢出:看不见的陷阱
整数溢出比缓冲区溢出更隐蔽。为什么?因为程序不会崩溃,但计算结果却是错的。我曾经在内存分配代码里踩过这个坑——一个看似无害的乘法,直接导致分配了过小的缓冲区。
2.1 整数溢出的常见场景
- 加法溢出: INT_MAX + 1 变成负数
- 乘法溢出: 两个大数相乘,结果超出类型范围
- 移位溢出: 左移位数超过类型位数
- 类型转换溢出: 大类型转小类型,高位被截断
2.2 一个真实案例
我记得有一次,一个同事写了一段内存分配代码:
// ❌ 危险写法
size_t size = count * sizeof(MyStruct);
void *buf = malloc(size);
如果 count 很大,比如 0x10000001,sizeof(MyStruct) 是 64,那么乘法结果会溢出。实际分配的内存远小于预期,后续写入就会越界。
正确的做法是:
// ✅ 安全写法
if (count > SIZE_MAX / sizeof(MyStruct)) {
// 处理溢出错误
return NULL;
}
size_t size = count * sizeof(MyStruct);
void *buf = malloc(size);
__builtin_add_overflow 等内建函数(GCC/Clang),可以方便地检测溢出。
2.3 整数溢出检查的通用方法
我整理了一个简单的检查宏,项目中一直在用:
#include <limits.h>
#include <stdbool.h>
// 加法溢出检查
bool add_overflow(size_t a, size_t b, size_t *result) {
if (a > SIZE_MAX - b) return true;
*result = a + b;
return false;
}
// 乘法溢出检查
bool mul_overflow(size_t a, size_t b, size_t *result) {
if (a != 0 && b > SIZE_MAX / a) return true;
*result = a * b;
return false;
}
三、格式化字符串安全:一个 %s 引发的血案
格式化字符串漏洞,听起来高大上,其实原理很简单。当用户输入被直接作为格式化字符串参数传递时,攻击者可以用 %x、%n 等格式符读取或写入内存。
3.1 错误 vs 正确
// ❌ 危险写法
printf(user_input); // 用户输入包含 %x%x%x 会泄露栈数据
// ✅ 安全写法
printf("%s", user_input); // 只作为字符串输出
我曾经在代码审查中看到过这样的代码:一个日志函数直接把用户输入的字符串传给 fprintf。我当时就指出——如果用户输入 "%p%p%p%p",栈上的指针地址就全暴露了。
3.2 格式化字符串攻击能做什么?
- 读取内存: 用 %x、%p 读取栈上的数据
- 写入内存: 用 %n 将已输出的字符数写入指定地址
- 任意地址读写: 结合 %n 和地址构造,可以实现任意内存写入
四、知识体系总览
下面这张图是我自己总结的安全编码知识体系,涵盖了今天讲的三个核心领域:
五、避坑指南与最佳实践
最后,分享几个我这些年总结出来的经验:
- 所有字符串操作函数,一律用带 n 的版本(strncpy、snprintf、strncat)
- 所有算术运算,先做溢出检查,再做计算
- 所有格式化输出,永远用
printf("%s", data)而不是printf(data) - 编译时开启 -Wall -Wformat -Wformat-security 等警告选项
- 用
strncpy以为就安全了,结果忘了它不会自动加 '\0'——缓冲区末尾没有终止符,照样溢出 - 检查整数溢出时只检查了加法,没检查乘法——结果乘法溢出导致分配了过小的缓冲区
- 以为只有 printf 有格式化字符串问题,忽略了 syslog、fprintf、sprintf 等函数
嗯,安全编码说白了就是「多留一个心眼」。每次写代码时问自己一句:这个输入是可信的吗?这个边界检查了吗?这个计算会溢出吗?养成习惯之后,你会发现很多漏洞在写代码的阶段就被消灭了。