在很多领域,如网络安全领域、汽车智能驾驶等领域都非常注重代码安全,这是确保产品质量和安全的关键环节,很多大公司如华为等都对产品的安全编码有着极高要求,并有相应的一套完整编程规范来引,像华为这种对产品质量要求极高的公司,还会有相应的安全编码考试,以培养员工的安全编码思想。安全编码不仅关于产品质量,在某些场景下更是避免软件灾难的最重要防线,对于从事软件开发人员来说不得不重视。.
一、安全编码的基本思想
开发人员在安全编码过程中应该保持如下的假设:
1、程序所处理的所有外部数据都是不可信的攻击数据;
外部数据定义如下:
- 文件
- 注册表
- 网络
- 环境变量
- 命令行
- 用户输入
- 用户态数据
- 进程间通信
- 函数参数(对于API)
- 全局变量(其他线程会修改全局变量)
2、攻击者时刻试图监听、篡改、破坏程序的运行环境、外部数据。
基于以上假设,得出安全编码基本思想:
(1)程序在处理外部数据时必须经过严格的合法性校验;
(2)尽量减少代码的攻击面。即代码的实现应该尽量简单,避免与外部环境做多余的数据交互。
(3)通过防御性的编码策略来弥补潜在的编码人员的疏忽。这些措施包括:
- 变量声明应该赋初值
- 谨慎使用全局变量
- 避免使用功能复杂、易用错的函数
- 禁用易用错的编码器的机制
- 小心处理资源访问过程
- 不要改变操作系统的运行环境
- 合理使用调试断言(ASSERT)
- 严格的错误处理
二、安全编码的要求
安全编码的内容非常广泛,涉及变量、断言、函数、循环、异常机制、类、安全退出、字符串/数组操作、整数、内存、不安全函数、文件输入/输出、敏感信息处理等各方面,这里简略叙述最重要的内存、函数、字符串处理等几个模块,以期能达到举一反三作用,引导大家树立安全编码思想。
1、内存
在C/C++编码中,内存的使用要非常谨慎,因为内存结合指针使用时非常灵活,稍微使用不当就可能造成段错误或内存泄漏等严重问题。内存的安全编码应该遵循如下要求:
(1)内存申请前,必须对申请内存的大小进行合法性校验
内存申请的大小值可能来自外部数据,必须检查其合法性,防止过多的、非法的申请内存。不能申请长度为0的内存。
int Foo(int size)
{
if (size <= 0) {
//error
...
}
...
char *msg = (char *)malloc(size);
if (msg == NULL) {
...
}
...
}
(2)内存分配后必须判断是否成功
如下
char *msg = (char *)malloc(size);
if (msg == NULL) {
...
}
(3)禁止引用未初始化的内存
malloc、new分配出来的内存没有被初始化为0,要确保内存被引用前是被初始化的。
可使用memset来初始化申请的内存,如下
int *CalcMetrixColomn(int **metrix, int *param, int size)
{
int *result = NULL;
...
int bufsize = size * sizeof(int);
...
result = (int *)malloc(bufsize);
...
int ret = memset(result, 0, bufsize); // 确保内存被初始化后才被引用
...
result[0] += metrux[0][0] * param[0];
...
return result;
}
(4)内存释放之后要赋予新值
内存释放之后,如果其指针未立即设置未NULL,也未分配一个新的对象,那么可能会导致该指针在后续代码中产生双重释放的风险,还存在访问已释放内存的危险。
如下
char *msg = NULL;
...
msg = (char *)malloc(len);
...
if (...) {
free(msg); // 在此分支对内存进行了释放
msg = NULL; // 释放后要立即将指针赋值为NULL,否则会为后续代码带来风险
}
...
if (msg != NULL) {
free(msg);
msg = NULL;
}
(5)禁止使用realloc函数
realloc函数原型如下:
void *realloc(void *p, int size);
该函数随着参数的不同,其行为也不一样,也就是一个函数被赋予了多种不同行为。这不是一个设计良好的函数,极易引发各种bug。
(6)禁止使用alloca函数申请栈上内存
该函数在有些平台下不支持,使用alloca函数会降低程序的兼容性和可移植性。该函数在栈帧里申请内存,申请的大小很可能超过栈的边界,影响后续的代码执行。
2、函数
(1)数组作为函数参数时,必须同时将其长度作为函数的参数
通过函数参数传递数组或一块内存进行写操作时,函数参数必须同时传递数组元素个数或所传递的内存块大小,否则函数在使用数组下标或访问内存偏移时,无法判断下标或便宜的合法范围,产生越界访问的漏洞。
如下
int ParseMsg(BYTE *msg, int msgLen)
{
ASSERT(msg != NULL);
ASSERT(msgLen != 0);
...
}
...
int len = ...
BYTE *msg = (BYTE *)malloc(len);
....
ParseMsg(msg, len); // 将msg的大小作为参数传递到函数中
....
以上代码msg为申请的内存块,对于固定长度的数组,也必须将其大小作为函数的参数传入。
对于const char *类型的参数,它的长度是通过'\0'的位置计算出来,不需要传长度参数。但如果是char *类型,且参数作为写内存的缓冲区,则需要传长度参数。
(2)不对内容进行修改的指针型参数,定义为const
如果参数是指针型类型,且内容不会被修改,应定义为const类型。
int Foo(const char *filePath)
{
...
int fd = open(filePath, ...);
...
}
(3)谨慎使用不可重入函数
不可重入函数在多线程环境下,其执行结果不能达到预期效果,需谨慎使用。常见的不可重入函数包括:rand、srand、getenv、getenv_s、strtok、strerro、setlocale、atomic_init、tmpnam、gethostbyaddr、gethostbyname等。
(4)字符串或指针作为函数参数时,请检查参数是否为NULL
如果字符串或者指针作为函数参数,为了防止空指针引用错误,在引用前必须确保该参数不为NULL,如果上层调用者已经保证了该参数不可能为NULL,在调用本函数时,在函数开始处可以加ASSERT进行校验。 例如下面的代码,因为BYTE *p有可能为NULL,因此在使用前需要进行判断。
int Foo(int *p, int count)
{
if (p != NULL && count > 0) {
int c = p[0];
}
...
}
int Foo2()
{
int *arr = ...
int count = ...
Foo(arr, count);
...
}