字符串安全:C++ 编程中的“暗礁”
聊到 C++ 安全,字符串这块儿绝对是个绕不开的坎儿。我做了这么多年代码审计,见过太多线上事故,追根溯源,十有八九都跟字符串处理脱不了干系。说白了,字符串就是程序与外界交互最频繁的接口,也是最容易出纰漏的地方。今天咱们就来好好捋一捋,怎么在 C++ 里把字符串这关守好。
C 风格字符串:老古董的“坑”
先说说 C 风格字符串,也就是 char* 或 char[]。这东西历史悠久,但问题也最多。我个人习惯是,新项目里能不用就不用,但老代码里到处都是,你躲不开。
核心问题是什么? 没有边界检查。你想想看,一个 char* 本质上就是个指针,它指向一块内存,但编译器并不知道这块内存有多大。你往里面写数据,全靠程序员自觉。一旦越界,轻则数据错乱,重则程序崩溃,甚至被黑客利用。
strcpy 这个函数。
来看个反面教材:
// 危险!千万别这么写
char buffer[10];
strcpy(buffer, user_input); // user_input 可能很长,直接溢出
这段代码,如果 user_input 超过 9 个字符(还要留一个给 '\0'),strcpy 就会把数据写到 buffer 之外的内存。嗯,这里要注意,strcpy 不会检查目标缓冲区的大小,它只管复制,直到遇到源字符串的结束符。
那怎么补救? 用 strncpy 吗?也不完全靠谱。我曾经见过一个案例,程序员用了 strncpy,但忘了手动添加结束符 '\0',结果后续的字符串操作函数(比如 strlen)读到了缓冲区之外的数据,一样出问题。
// 看似安全,实则仍有隐患
char buffer[10];
strncpy(buffer, user_input, sizeof(buffer) - 1);
// buffer[sizeof(buffer) - 1] = '\0'; // 这行忘了写!
所以,我的建议是:在 C++ 代码里,尽量别碰 C 风格字符串。如果实在要用,务必记住三点:
- 明确缓冲区大小,并留出结束符的位置。
- 使用
strncpy、snprintf等带长度限制的函数。 - 手动添加结束符,别指望编译器帮你。
std::string:现代 C++ 的“救星”
好在 C++ 标准库给我们提供了 std::string。这东西用起来就省心多了。它自己管理内存,自动扩容,自动添加结束符。你想想看,这得省多少事。
为什么 std::string 更安全?
- 自动内存管理:你只管往里塞数据,它自己会分配和释放内存,不用担心缓冲区溢出。
- 边界检查:通过
at()方法访问字符时,会抛出std::out_of_range异常。当然,operator[]不检查,但你可以选择用at()。 - 与 C 风格字符串兼容:通过
c_str()方法可以获取const char*,方便与老接口交互。
我个人习惯是,所有字符串操作都用 std::string。除非有性能瓶颈,而且经过 profile 确认,否则绝不退回到 C 风格字符串。
std::string 比 char* 高效得多。因为 std::string 会预分配一些额外空间,减少重新分配的次数。
但 std::string 也不是万能的。比如,在多线程环境下,std::string 本身不是线程安全的。你需要自己加锁,或者用 std::atomic 之类的机制。另外,std::string 的拷贝是深拷贝,如果字符串很大,频繁拷贝会有性能问题。这时候可以考虑用 std::string_view 来避免拷贝。
字符串格式化漏洞:printf 家族的“魔咒”
接下来聊一个经典漏洞——格式化字符串漏洞。这东西在 CTF 比赛里很常见,但在真实项目中,我见过不止一次。
漏洞是怎么产生的? 简单说,就是 printf 系列函数的格式化参数(第一个参数)被用户控制了。比如:
// 危险!千万别这么写
printf(user_input); // 用户输入 "%x %x %x %x" 就能泄露栈上数据
如果 user_input 是 "Hello, %s!",那没问题。但如果 user_input 是 "%x %x %x %x",printf 就会从栈上读取数据,并以十六进制形式打印出来。攻击者可以利用这个漏洞,读取任意内存地址的数据,甚至写入数据。
为什么会这样?因为 printf 不知道你给了多少个参数。它根据格式化字符串里的占位符,从栈上依次取参数。如果占位符比实际参数多,它就会读取栈上不属于它的数据。
正确的做法是什么? 永远不要直接把用户输入作为格式化字符串。应该这样写:
// 安全写法
printf("%s", user_input); // 把用户输入作为 %s 的参数
或者用 C++ 的 std::cout:
std::cout << user_input; // 更安全,不会解析格式化占位符
安全格式化函数:给 printf 加上“安全带”
如果你必须用 printf 家族的函数,那至少要用带长度限制的版本。比如 snprintf 代替 sprintf,vsnprintf 代替 vsprintf。
snprintf 会限制写入的字符数,防止缓冲区溢出。它的原型是:
int snprintf(char *buffer, size_t bufsz, const char *format, ...);
bufsz 参数指定了缓冲区的大小。snprintf 最多写入 bufsz - 1 个字符,然后自动添加结束符。这样,即使格式化后的字符串很长,也不会溢出缓冲区。
来看个对比:
char buffer[10];
// 危险
sprintf(buffer, "Value: %d", 12345); // 如果 "Value: 12345" 超过 9 个字符,就溢出了
// 安全
snprintf(buffer, sizeof(buffer), "Value: %d", 12345); // 最多写入 9 个字符 + '\0'
我个人建议,在 C++ 代码里,优先用 std::ostringstream 或 std::format(C++20 引入)。它们类型安全,不会出现格式化字符串漏洞,而且可读性更好。
// C++20 的 std::format
std::string result = std::format("Value: {}", 12345); // 安全、清晰
知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
这张图把本章的核心内容串起来了。左边是 C 风格字符串的坑,中间是 std::string 的解决方案,右边是格式化漏洞的攻防。最下面是最终的解决方案。
避坑指南
最后,分享几个我亲身踩过的坑:
- 我曾经在维护一个遗留系统时,发现一个
sprintf调用,缓冲区大小写死了 256 字节。结果某天用户输入了一个超长的字符串,直接导致程序崩溃。后来我改成snprintf,问题解决。 - 我曾经在代码审查中,看到有人用
std::string::c_str()返回的指针,去调用一个会修改字符串内容的函数。结果指针失效,导致野指针访问。记住,c_str()返回的指针在字符串被修改后就会失效。 - 我曾经在调试一个诡异的崩溃时,发现是
printf的格式化字符串被用户输入污染了。攻击者输入了一串%n,试图往内存里写数据。幸好我们及时发现,否则后果不堪设想。
嗯,字符串安全这块儿,说难不难,说简单也不简单。关键是要养成良好的编码习惯,时刻保持警惕。记住:永远不要信任外部输入,这是安全编程的第一条铁律。