安全编码实践:缓冲区溢出防护、整数溢出检查、格式化字符串安全

说实话,安全编码这块,我以前也吃过亏。刚入行那会儿,总觉得「功能跑通就行」,什么缓冲区溢出、整数溢出,都是理论课上的东西。直到有一次,我负责的一个网络模块在压力测试下直接崩溃,排查了一整天才发现——嗯,就是一个 sprintf 没控制长度。从那以后,我再也不敢小看这些「小细节」了。

今天咱们就聊聊三个最常见的坑:缓冲区溢出、整数溢出、格式化字符串漏洞。每个我都会结合自己的踩坑经历来讲,希望能帮你少走弯路。

一、缓冲区溢出:最经典的漏洞

缓冲区溢出,说白了就是往一个固定大小的数组里写了超出容量的数据。多余的数据会覆盖相邻内存,轻则程序崩溃,重则被攻击者利用执行恶意代码。

⚠️ 警告: 缓冲区溢出是 C 语言中最常见、最危险的安全漏洞之一。很多著名的蠕虫病毒(比如 Morris 蠕虫)就是靠这个传播的。

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);
💡 小技巧: 我建议你养成一个习惯——所有涉及字符串拼接的地方,一律用 snprintf 或带长度限制的函数。别嫌麻烦,这能省掉 90% 的缓冲区溢出问题。

二、整数溢出:看不见的陷阱

整数溢出比缓冲区溢出更隐蔽。为什么?因为程序不会崩溃,但计算结果却是错的。我曾经在内存分配代码里踩过这个坑——一个看似无害的乘法,直接导致分配了过小的缓冲区。

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);
🔑 核心原则: 在进行算术运算之前,先判断是否可能溢出。C 标准库提供了 __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 和地址构造,可以实现任意内存写入
⚠️ 警告: 永远不要将用户输入直接作为格式化字符串的第一个参数。这是铁律,没有例外。

四、知识体系总览

下面这张图是我自己总结的安全编码知识体系,涵盖了今天讲的三个核心领域:

C语言安全编码实践知识体系 缓冲区溢出 • 危险函数识别 • 栈缓冲区溢出 • 堆缓冲区溢出 • 边界检查 • 安全替代函数 • 栈保护机制 • ASLR 与 NX 整数溢出 • 加法溢出 • 乘法溢出 • 移位溢出 • 类型转换溢出 • 溢出检测方法 • 安全算术运算 • 使用内建函数 格式化字符串 • %n 写入漏洞 • %x 信息泄露 • 任意地址读写 • 参数数量不匹配 • 使用 %s 包装 • 静态分析工具 • 代码审查要点 核心原则:永远不要信任外部输入,始终验证边界和类型

五、避坑指南与最佳实践

最后,分享几个我这些年总结出来的经验:

📌 我的个人习惯:
  • 所有字符串操作函数,一律用带 n 的版本(strncpy、snprintf、strncat)
  • 所有算术运算,先做溢出检查,再做计算
  • 所有格式化输出,永远用 printf("%s", data) 而不是 printf(data)
  • 编译时开启 -Wall -Wformat -Wformat-security 等警告选项
⚠️ 我曾经踩过的坑:
  • strncpy 以为就安全了,结果忘了它不会自动加 '\0'——缓冲区末尾没有终止符,照样溢出
  • 检查整数溢出时只检查了加法,没检查乘法——结果乘法溢出导致分配了过小的缓冲区
  • 以为只有 printf 有格式化字符串问题,忽略了 syslog、fprintf、sprintf 等函数

嗯,安全编码说白了就是「多留一个心眼」。每次写代码时问自己一句:这个输入是可信的吗?这个边界检查了吗?这个计算会溢出吗?养成习惯之后,你会发现很多漏洞在写代码的阶段就被消灭了。


公众号:蓝海资料掘金营,微信 deep3321