先来看一个“看起来人畜无害“的 strcpy

先来看一个“看起来人畜无害“的 strcpy
你有没有遇到过这种事字符串拷着拷着程序就崩了但代码翻来覆去看都没看出问题在哪。更诡异的是有时候 Debug 模式跑得好好的一编译 Release发给客户就崩。你怀疑是 strcpy 的问题但又说不清它到底怎么搞崩的。其实问题不在 strcpy 这个函数本身——它从来不检查目标缓冲区够不够大。它只会傻傻地从源地址一直复制直到遇到 ‘\0’ 为止。如果源字符串比目标缓冲区长多出来的字符就会毫不客气地写到相邻的内存区域去。今天我们就用 VS2013 来彻底扒开 strcpy 越界拷贝的真相看看它到底是怎么悄无声息地把你的程序搞崩溃的。先来看一个看起来人畜无害的 strcpy在 VS2013 里新建一个控制台项目写入下面这段代码#includestdio.h#includestring.hintmain(){charsrc[]Hello;chardst[5]{0};strcpy(dst,src);printf(dst %s\n,dst);return0;}这段代码看起来没什么问题对吧“Hello” 刚好 5 个字符dst 也是 5 个字节一对应刚刚好。不对。字符串字面量 “Hello” 实际上占 6 个字节——每个字符串末尾都有一个隐式的 ‘\0’ 结束符。strcpy 会把这个 ‘\0’ 也一起复制过去。所以 src 实际是 6 个字节dst 只有 5 个字节多出来的 ‘\0’ 写到了 dst[5] 的位置——越界了。只不过因为越界只写了 1 个字节而且刚好是个 0所以程序没崩溃。但这种刚好没事是最危险的——它会让你放松警惕直到有一天碰上真正的问题。真正的噩梦strcpy 超长拷贝上面的例子充其量算个警告真正要命的是下面这种情况#includestdio.h#includestring.hintmain(){chardst[8]{0};charpassword[8]1234567;strcpy(dst,Hello World!!!);/* 源字符串 14 个字符 \0 15 字节 */printf(dst %s\n,dst);printf(password %s\n,password);return0;}在 VS2013 里运行这段代码结果是dst 正常打印了 “Hello World!!!”password 也被打印出来了而且值被改写了如果越界得更多直接崩溃0xC0000005: 访问冲突你只是调了一个 strcpy结果 password 变量的值莫名其妙被改了。你的程序里没有任何一行代码直接修改过 password但它就是变了。背后的真相栈上变量的内存布局在 VS2013 Debug 模式下局部变量在栈上是从高地址向低地址排列的。先声明的变量在高地址后声明的变量在低地址。上面代码的变量声明顺序是dst→password所以在栈上是这样分布的------------------- ← 高地址栈底方向 | dst[0] | | dst[1] | | ... | | dst[7] | ← dst 合法范围到此为止 | password[0] | ← dst[8] 实际指向这里 | password[1] | | ... | | password[7] | | (返回地址 / 其他) | ← 再往下写直接崩 ------------------- ← 低地址栈顶方向strcpy(dst, “Hello World!!!”) 的复制过程是这样的从 dst[0] 开始写入 ‘H’、‘e’、‘l’、‘l’、‘o’、’ 、‘W’——dst[0]~dst[6]没问题继续写入 ‘o’、‘r’、‘l’、‘d’——dst[7] 被写入这是 dst 的最后一个字节还勉强合法继续写入 ‘!’、‘!’、‘!’、‘\0’——dst[8]、dst[9]、dst[10]、dst[11] 全部越界dst[8] 对应的正是 password[0]password 被 overwrite 了strcpy 不会在 dst[7] 之后停下来它根本没有目标缓冲区边界的概念。它只认一个条件源字符串遇到 ‘\0’ 才停。VS2013 的 Debug 模式会帮你检测到吗这是一个好问题。VS2013 Debug 模式会在未初始化的栈内存里填充 0xCC但上面代码中 dst 和 password 都显式初始化了所以 0xCC 没起作用。如果你在 strcpy 之后打印 password看到的不是 0xCCCCCCCC而是被 ‘!’ 和 ‘\0’ 覆盖后的内容。不过 VS2013 提供了一个编译器选项在这种特定情况下可能有用——打开属性 → C/C → 代码生成 → 基本运行时检查选两者(/RTCs, /RTCu)。但 /RTCs 检测的是函数返回时栈哨兵有没有被破坏它不一定能抓到 strcpy 这种温和的越界——尤其是当越界范围没碰到哨兵的时候。Debug 和 Release 的区别为什么 Debug 没事 Release 崩上面这段代码在 VS2013 Debug 模式下可能不会崩——因为 Debug 模式会在变量之间插入额外的填充字节0xCC这些填充像缓冲带一样吸收了一部分越界写入。但 Release 模式一开优化/O1 或 /O2就完全不一样了没有 0xCC 填充变量之间没有缓冲带越界直接覆盖相邻变量变量可能重排编译器优化可能会改变变量在栈上的顺序你在 Debug 下测出来的越界后果在 Release 下完全对不上寄存器优化短字符串可能直接被优化到寄存器里strcpy 的行为更加不可预测最严重的情况是 strcpy 越界覆盖了函数的返回地址。一旦函数返回CPU 跳转到一个被覆盖的非法地址秒崩0xC0000005。更隐蔽的坑strcpy 和 strncpy 的区别很多 C 语言教材会告诉你不安全用 strcpy安全用 strncpy。但strncpy 也不是完全的解决方案#includestdio.h#includestring.hintmain(){chardst[5]{0};strncpy(dst,Hello World!!!,5);/* dst 没有被 \0 终止 */printf(dst %s\n,dst);/* 会继续往后打印直到遇到 0 */return0;}strncpy(dst, src, n) 最多复制 n 个字符但它不保证以 ‘\0’ 结尾。如果 src 的前 n 个字符中没有 ‘\0’那么 dst 就不会以 ‘\0’ 结尾。这时候你 printf 它它会一直往后读内存直到在某个地方碰到一个 0或者直接访问到非法地址崩溃。正确的用法是手动加 ‘\0’chardst[5]{0};strncpy(dst,Hello World!!!,4);/* 留一个位置给 \0 */dst[4]\0;/* 手动保证字符串结束 */写一个完整的实验看看越界到底改了什么下面这段代码可以直观地看到 strcpy 越界时每个字节的变化#includestdio.h#includestring.hintmain(){inti;/* 两个缓冲区相邻排列 */charbuffer2[8]1234567;/* 先声明在高地址 */charbuffer1[8]{0};/* 后声明在低地址 */printf(越界前各变量的值\n);printf(buffer1 %s\n,buffer1);printf(buffer2 %s\n,buffer2);/* 打印内存地址 */printf(buffer1 的起始地址: %p\n,buffer1);printf(buffer2 的起始地址: %p\n,buffer2);printf(地址差: %d 字节\n,(int)(buffer2-buffer1));/* 执行越界拷贝 */strcpy(buffer1,AAAAAAAAAAAA);/* 12 个 A \0 13 字节buffer1 只有 8 字节 */printf(\n越界后各变量的值\n);printf(buffer1 %s\n,buffer1);printf(buffer2 %s\n,buffer2);/* 逐字节打印内存内容 */printf(\nbuffer1 周围内存内容十六进制\n);for(i-2;i18;i){printf(buffer1[%d] 0x%02x,i,(unsignedchar)buffer1[i]);if(i0i8)printf( - buffer1 合法范围);if(i8i16)printf( - buffer2 范围越界);printf(\n);}return0;}在 VS2013 里跑一下你会看到buffer1[8]~buffer1[15] 写入的数据实际上覆盖了 buffer2 的内容逐字节打印可以看到越界写入的每一个 ‘A’ 和最后的 ‘\0’地址差可以告诉你两个缓冲区在栈上的实际距离strcpy 越界的系统级预防1. 能不用就不用用 strcpy_sVS2013 提供了 secure CRT 版本#define__STDC_WANT_LIB_EXT1__1#includestring.hchardst[8]{0};strcpy_s(dst,sizeof(dst),Hello World!!!);/* 越界时会触发约束处理 */strcpy_s 会检查目标缓冲区大小如果源字符串太长不会执行拷贝而是调用约束处理函数默认是 abort。2. 一定要用 strcpy 的话先算长度chardst[8]{0};constchar*srcHello World!!!;if(strlen(src)sizeof(dst)){strcpy(dst,src);}else{printf(错误源字符串太长无法安全复制\n);/* 改用 strncpy 或动态分配 */}3. 活用 sizeof 和编译时断言在 VS2013 中对数组名取 sizeof 得到的是数组的实际字节数。但对于退化成指针的数组名sizeof 只返回指针的大小32 位下是 4 字节x64 下是 8 字节。注意区分chardst[32]{0};char*pdst;sizeof(dst);/* 32——数组的实际大小 */sizeof(p);/* 432位程序——指针的大小不是数组的大小 */4. 静态分析工具VS2013 的代码分析工具右键项目 → 分析 → 运行代码分析可以检测出部分 strcpy 越界问题。它会警告C6059: 调用 strcpy 时参数长度不正确或者C6200: 索引超出缓冲区的有效范围。养成每次编译前跑一次代码分析的习惯。5. Debug 模式下启用 /RTCs前面已经说过虽然 /RTCs 不能抓到所有越界但对于那些覆盖到栈哨兵的越界它能帮你及时发现。在项目属性 → C/C → 代码生成 → 基本运行时检查 → 选两者(/RTCs, /RTCu)。一个经典的 strcpy 越界导致的安全漏洞strcpy 越界不仅仅是程序崩溃的问题——它是一个严重的安全漏洞。最著名的例子是 1988 年的 Morris 蠕虫它利用的就是 fingerd 程序中的 strcpy 越界漏洞。攻击原理其实很简单如果 strcpy 的目标缓冲区在栈上而且越界写入覆盖了函数的返回地址攻击者就可以让函数返回到一段恶意代码去执行。这就是缓冲区溢出攻击buffer overflow attack的基本原理。在 VS2013 中由于默认启用了 GS缓冲区安全检查/GS 编译选项编译器会在函数入口处往栈上放一个 security_cookie函数返回前检查它有没有被改写。如果被改了程序会直接终止防止攻击者利用越界控制返回地址。但 GS 不是万能的越界写入不总是能触发 GS 检测。总结strcpy 越界的几个等级越界程度现象后果越界 1~3 字节程序正常相邻变量被轻微改写逻辑 Bug极难排查越界 4~N 字节相邻变量被大量覆盖GS 可能触发逻辑崩溃安全漏洞越界到返回地址区域函数返回时秒崩 0xC0000005程序崩溃安全漏洞Release 模式无 GS越界覆盖返回地址后可执行恶意代码安全攻击今日思考题如果把char buffer2[8] 1234567的声明移到char buffer1[8]之后先声明 buffer1再声明 buffer2strcpy 越界后被改的还是 buffer2 吗栈上的排列顺序会变成什么样动手在 VS2013 里试一下标签#C语言#strcpy#数组越界#缓冲区溢出#VS2013#调试技巧