华为CC++安全编程规范实战:从内存越界到防御性编码 简介《华为CC语言安全编程规范-V3.1》面向C/C开发人员与安全编码学习者旨在培养攻击者思维、建立安全编码习惯从源头降低代码被监听、篡改与破坏的风险。规范围绕外部数据不可信这一前提系统梳理了基础要求、字符串与数组操作、安全函数使用、整数运算、内存管理、不安全函数、文件输入输出及敏感信息处理等模块并给出变量初始化、断言使用、函数参数校验、循环退出条件、禁用C异常机制、类与析构规则、缓冲区溢出防护、整数溢出与类型转换检查、内存申请与释放规范等具体规则与建议附录还整理了SQL注入与命令注入相关特殊字符、危险函数替换列表及异步安全函数清单。资源为1个PDF文件压缩包约911KB结构清晰、条目分明便于按章节查阅与团队内部培训引用。目前已有1175人学习下载适合希望系统掌握C/C安全编码要点、对照排查代码隐患的开发者参考。1. 从一次内存越界排查说起这份规范到底解决什么问题去年帮一个团队排查一个偶发的崩溃现象很玄学压测跑几个小时才崩一次core 文件里栈已经烂掉gdb 回溯出来的调用链根本对不上。最后定位到一行代码——memset(buffer, 0, sizeof(buffer))而buffer是个char *指针参数。写这行的人以为自己在清空整个缓冲区实际上只清了指针本身那 8 个字节后面该清的内存一个没动残留数据被当成有效长度用越界写穿了栈。这类问题不是靠 code review 抓得住的因为它看起来太正常了。华为这份《CC语言安全编程规范 V3.1》要解决的正是这一类问题把 C/C 里那些语法合法但语义危险的写法用规则和建议的形式钉死。它覆盖变量初始化、断言、函数参数、整数运算、内存管理、字符串操作、安全函数、文件 IO、敏感信息处理等模块每一条都配了正反示例和对应的 CERT/MISRA/CWE 编号。适合谁看写 C/C 的、做嵌入式或系统层的、需要过安全编码审计的以及被内存问题折磨过想建立防御习惯的人。下面我按怎么落地的顺序把它拆开。2. 攻击者思维与外部数据规范的地基怎么打2.1 为什么先讲思维再讲规则这份规范最容易被跳过的是前言但它恰恰是整份文档的地基。前言里提了两个假设程序处理的所有外部数据都是不可信的攻击数据攻击者时刻试图监听、篡改、破坏运行环境和外部数据。基于这两个假设推出三条编码思想——外部数据必须严格校验、尽量减少代码攻击面、用防御性策略弥补疏忽。很多人拿到规范直接翻到规则列表开始对照改代码改完发现每条都遵守了但还是出问题。原因就在于规则是散的思维是纲。举个具体例子规则 2.3 要求外部数据作为数组索引时必须确保在数组大小范围内如果你没有外部数据不可信这个前提就会觉得这个索引是上层传进来的上层肯定校验过了于是不加判断直接用。而攻击者思维要求你反过来想上层可能被绕过这个值可能来自网络包必须在本函数内再校验一次。外部数据的定义在规范里列得很细文件含配置文件、注册表、网络、环境变量、命令行、用户输入、用户态数据、进程间通信管道/消息/共享内存/socket/RPC、函数参数对 API 而言、全局变量本函数内其他线程会改的。这个清单建议背下来因为后面几乎所有规则都在回答同一个问题这个数据是不是外部的如果是校验了吗2.2 把外部数据落到代码里的判断方法实际写代码时怎么快速判断一个数据是不是外部数据我的习惯是沿着数据流往回追追到它第一次进入程序边界的地方。如果那个边界是文件读取、socket recv、getenv、argv、注册表查询那它就是外部数据从边界到使用点之间的每一跳都要有校验。下面这段代码演示了规范里外部数据必须校验的落地写法也是规则 2.3 和 2.4 的典型场景#include stdint.h #include string.h #include stdlib.h #define MAX_ITEMS 64 #define MAX_COPY_LEN 256 /* 外部数据idx 和 len 都可能来自网络包或配置文件 */ int ProcessPacket(const uint8_t *payload, size_t payloadLen, uint32_t idx, uint32_t copyLen) { uint8_t table[MAX_ITEMS] {0}; uint8_t dst[MAX_COPY_LEN] {0}; /* 规则2.3外部数据作数组索引先校验范围 */ if (idx MAX_ITEMS) { return -1; } /* 规则2.4外部数据作复制长度先校验合法性 */ if (copyLen MAX_COPY_LEN || copyLen payloadLen) { return -1; } /* 规则2.1确保有足够存储空间后再操作 */ memcpy(dst, payload, copyLen); table[idx] dst[0]; return 0; }逻辑说明idx和copyLen都是外部可控的所以进函数后第一件事是范围校验而不是直接拿去索引或当长度。参数说明MAX_ITEMS是数组容量idx MAX_ITEMS挡住上界溢出copyLen同时受目标缓冲区大小和源数据实际长度两个约束缺一不可——只校验copyLen MAX_COPY_LEN而不管payloadLen当payloadLen比copyLen小时memcpy会读越界。提示规范里规则是必须遵守的建议是必须考虑的例外极少。改代码时先过规则再评估建议别把建议当可选项直接忽略。3. 变量、断言与函数参数三条最容易翻车的规则3.1 变量初始化与 sizeof 陷阱规则 1.1.1 到 1.1.4 讲变量其中 1.1.4严禁对指针变量进行 sizeof 操作是血泪重灾区。开头那个崩溃就是这条。规范给的判断方法很直接要判断指针类型本身的大小用sizeof(char *)要判断指针指向的数组大小必须另外传长度参数不能靠 sizeof。规则 1.1.2 讲资源释放后立即赋新值这条在 socket 和文件描述符场景下尤其重要。释放后不置空后续代码如果又走到释放路径就是 double free如果又拿去用就是 use-after-free。规范给的例外是释放语句刚好在变量作用域最后一句但我的习惯是无条件置空因为维护代码的人不一定知道那是最后一句。#include stdlib.h void ResourceDemo(void) { char *msg NULL; /* 规则1.1.1声明即赋初值 */ int fd -1; msg (char *)malloc(128); if (msg NULL) { /* 规则5.2分配后必须判断成功 */ return; } /* ... 使用 msg ... */ free(msg); msg NULL; /* 规则1.1.2释放后立即赋新值 */ /* 后续即使再走到 free(msg)free(NULL) 是安全的 */ free(msg); }逻辑说明声明时给初值避免条件分支跳过赋值后被误用释放后置 NULL让重复释放变成无害操作。参数说明fd -1对应无效描述符约定socket 场景用INVALID_SOCKET文件场景用-1按平台约定来。3.2 断言的正确用法与禁用场景断言这块规范给了三条规则一条建议核心是断言只在调试版有效发布版必须编译掉断言不能用于运行时可能发生的错误断言内不能改变运行环境。规则 1.2.2 的示例特别典型——文件打开失败、内存分配失败、字符串查找不到、文件内容不符合预期这些全是运行时可能发生的用 ASSERT 去校验就是错的。因为发布版断言被编译掉后这些错误就没人处理了程序带着错误状态继续跑。#include assert.h #ifdef DEBUG #define ASSERT(f) assert(f) #else #define ASSERT(f) ((void)0) #endif /* 正确断言校验的是设计上不可能发生的情况 */ int ParseConfig(const char *path, int *outValue) { ASSERT(path ! NULL); /* 调用者保证非空这是设计约定 */ ASSERT(outValue ! NULL); FILE *fp fopen(path, r); if (fp NULL) { /* 运行时可能失败用 if 不用 ASSERT */ return -1; } /* ... */ fclose(fp); return 0; }逻辑说明ASSERT宏在非 DEBUG 版本展开为((void)0)保证发布版不引入断言开销和退出行为。参数说明DEBUG宏由编译选项控制常见做法是-DDEBUG只在调试构建里加。规则 1.2.3 要特别注意——ASSERT(i 1000)、ASSERT(close(fd) 0)这类写法在发布版里整个表达式被丢掉i不自增、fd不关闭调试版和发布版行为不一致这是最隐蔽的坑。3.3 函数参数的长度与 const规则 1.3.1 要求数组作参数时必须同时传长度这条在写解析类函数时是硬约束。规范给的例外有两个const char *靠\0定长可以不传固定长度数组且下标不受外部数据影响、性能敏感时可以省。但例外二是把双刃剑我见过太多人拿性能敏感当借口省掉长度参数结果后来函数被复用到别的场景就出事了。规则 1.3.3 要求不修改内容的指针参数定义为 const这条配合编译器能挡掉一批误写。建议 1.3.2 讲参数 NULL 检查如果合法性由调用者保证函数开头用 ASSERT如果可能为 NULL用 if 判断。区分标准就是这个 NULL 是设计错误还是运行时正常情况。#include string.h /* 正确写缓冲区传长度只读指针加 const */ int SaveName(char *name, size_t len, const char *inputName) { if (name NULL || inputName NULL || len 0) { return -1; } if (strlen(inputName) len) { /* 规则2.2确保有结束符空间 */ return -1; } strcpy(name, inputName); return 0; }逻辑说明name是写缓冲区必须带leninputName只读加const长度校验用而不是因为要给\0留位置。参数说明len是目标缓冲区总容量不是可写字符数这个语义要在函数注释里写清楚否则调用者容易传错。4. 整数、内存与不安全函数溢出和释放的排查清单4.1 整数运算的溢出与类型转换规则 4.1 到 4.6 讲整数核心是运算前校验、比较前提升类型、有符号数不做位运算、整数指针不互转、指针不做逻辑位运算、循环次数受外部控制要校验。规则 4.2 特别容易被忽略整型表达式比较或赋值给更大类型之前必须用更大类型对它求值。看这段#include stdint.h /* 错误写法a b 在 32 位下先溢出再赋给 64 位 */ int64_t BadSum(uint32_t a, uint32_t b) { return a b; /* ab 按 uint32_t 运算可能回绕 */ } /* 正确写法先提升到 64 位再运算 */ int64_t GoodSum(uint32_t a, uint32_t b) { return (int64_t)a (int64_t)b; }逻辑说明C 的整型提升规则是先按操作数中较大的类型算两个uint32_t相加结果还是uint32_t溢出发生在赋值之前。参数说明(int64_t)a是显式类型转换把运算拉到 64 位域里做。规则 4.3 禁止对有符号整数做位运算因为负数右移是实现定义行为不同编译器结果可能不同这类问题跨平台时就是玄学。4.2 内存管理的五条硬规则规则 5.1 到 5.6 讲内存我把它整理成一张排查表改代码时逐条对照规则要求典型翻车场景5.1申请前校验大小合法性malloc(len)中 len 来自外部且未校验为 0 或极大值5.2分配后判断是否成功直接解引用 malloc 返回值NULL 时崩溃5.3禁止引用未初始化内存malloc 后未 memset 直接读拿到脏数据5.4释放后立即赋新值double free、use-after-free5.5禁止使用 realloc失败时原指针丢失内存泄漏5.6禁止使用 alloca栈上分配大小不可控栈溢出规则 5.5 禁用 realloc 的理由值得展开p realloc(p, newSize)这种写法在 realloc 失败时返回 NULL原p指向的内存还在但指针被覆盖直接泄漏。规范选择直接禁用改用申请新块-拷贝-释放旧块的三步走虽然啰嗦但可控。#include stdlib.h #include string.h /* 规则5.5的替代写法不用 realloc */ int GrowBuffer(char **buf, size_t oldSize, size_t newSize) { if (buf NULL || newSize 0 || newSize oldSize) { return -1; } char *newBuf (char *)malloc(newSize); if (newBuf NULL) { /* 规则5.2 */ return -1; } memset(newBuf, 0, newSize); /* 规则5.3清零避免脏数据 */ if (*buf ! NULL) { memcpy(newBuf, *buf, oldSize); free(*buf); *buf NULL; /* 规则5.4 */ } *buf newBuf; return 0; }逻辑说明先申请新块并判断成功再拷贝旧数据最后释放旧块并置空任何一步失败都不会丢原指针。参数说明oldSize是旧块有效数据长度newSize是新块总容量要求newSize oldSize。4.3 不安全函数的替换清单规则 6.1 到 6.6 讲不安全函数附录 C 给了危险函数及替换的安全函数列表。核心几类进程启动函数system、popen、WinExec、ShellExecute、exec 系列、CreateProcess禁止用外部可控数据作参数模块加载函数dlopen、LoadLibrary同理SQL 拼接禁止用外部数据信号处理例程中禁止调用非异步安全函数禁用 setjmp/longjmp禁用内存操作类危险函数。命令注入和 SQL 注入的特殊字符在附录 A、B 里列了但规范的态度很明确不是靠过滤特殊字符来防而是从设计上就不让外部数据进入这些函数的参数位。过滤是补丁隔离才是根治。5. 避坑与排查五条真实踩过的记录5.1 断言在发布版被编译掉导致逻辑缺失现象调试版跑得好好的发布版某个计数器不增长功能异常。 原因代码里写了ASSERT(i 0)发布版 ASSERT 展开为((void)0)i根本没执行。 解决断言内只放纯判断表达式任何有副作用的操作自增、赋值、函数调用都挪到断言外面。改完用grep -n ASSERT(.*\|ASSERT(.*扫一遍全量代码。5.2 sizeof 指针当数组长度用现象memset/memcpy 后数据不对或偶发越界崩溃。 原因函数参数是char *buf函数内写sizeof(buf)拿到的是指针大小4 或 8 字节不是缓冲区大小。 解决凡是数组作参数长度必须作为独立参数传入函数内禁止对指针参数做 sizeof。规范规则 1.1.4 和 1.3.1 就是针对这个。5.3 安全函数返回值不检查现象用了strcpy_s、memcpy_s这类安全函数但程序行为还是异常。 原因安全函数在参数非法时返回错误码且不执行拷贝如果调用者不检查返回值目标缓冲区保持原样后续代码拿到的还是旧数据或未初始化数据。 解决规则 3.5 要求必须检查安全函数返回值并正确处理。我的习惯是每个安全函数调用后立刻判断错误路径要么返回要么跳转到清理逻辑。5.4 destMax 参数传成了源长度现象strcpy_s(dst, srcLen, src)编译通过但行为诡异。 原因安全函数的 destMax 参数是目标缓冲区容量不是源字符串长度。传错会导致两种后果传大了目标溢出传小了正常数据被截断。 解决规则 3.1 专门讲这个。统一约定destMax 一律传sizeof(dst)dst 是数组时或目标缓冲区实际容量dst 是指针时。5.5 敏感信息用完没清零现象内存 dump 或 core 文件里能翻出密码、密钥明文。 原因规则 8.2 要求敏感信息使用完毕后立即清 0但很多人只 free 不 memsetfree 只是把内存还给分配器数据还在。 解决敏感数据用完后先memset(ptr, 0, len)再 free且 memset 不能被编译器优化掉——常见做法是用volatile指针或平台提供的SecureZeroMemory类函数。规则 8.3 还要求别用 string 类存敏感信息因为 string 的扩容和拷贝会在多处留下副本清不干净。6. 把规范变成习惯我的落地检查清单规范读完容易落地难。我的做法是把它拆成三道关卡每道关卡对应不同的检查时机。第一道是编码时。写每个函数时问三个问题参数是不是外部数据是的话校验了吗数组和缓冲区传长度了吗这三个问题能挡掉规范里一半以上的规则违反。具体到写法我固定了几个模板函数开头先判空再判长度写缓冲区一律带 len 参数只读指针一律加 constmalloc 后立刻判 NULL 并 memset。第二道是提交前。用静态检查工具扫一遍重点看几类告警sizeof 用在指针上、有符号数位运算、整数运算可能溢出、危险函数调用。工具报的不一定都是真问题但每一类都要人工确认。下面这个 grep 组合是我常用的快速自查配合代码审查用# 扫描常见的规范违反模式 grep -rn sizeof(.*\* --include*.c --include*.cpp . # 指针sizeof grep -rn realloc\|alloca --include*.c --include*.cpp . # 禁用函数 grep -rn ASSERT(.*\|ASSERT(.* --include*.c . # 断言副作用 grep -rn strcpy\|sprintf\|gets --include*.c . # 危险函数逻辑说明这四条覆盖了最高频的翻车点sizeof那条会有些误报比如sizeof(char *)是合法的需要人工过滤。参数说明--include限定文件类型避免扫到构建产物-n带行号方便定位。第三道是发布前。确认所有 ASSERT 在发布构建里被编译掉确认敏感信息清理逻辑存在确认安全函数返回值都有处理。这一步我一般写成一个 checklist 贴在构建脚本旁边每次发版前过一遍。最后说一个我自己的习惯。规范里规则 1.1.2资源释放后立即赋新值我一开始觉得啰嗦直到有次在多层错误处理里漏置空导致 double free排查了大半天。从那以后我每次写完 free/close 都强制走一遍置空动作哪怕它看起来是最后一句。这种防御性写法单看每一处都像多余但它在错误路径和后续维护里救的场远比写的时候多花的那几秒值。希望帮到你。本文还有配套的精品资源点击获取