攻击者思维
编程过程中应该时刻保持以下的假设:
1. 程序所处理的所有外部数据都是不可信的攻击数据
2. 攻击者时刻试图监听、篡改、破坏程序运行环境、外部数据
安全编码基本思想
基于以上的假设,得出安全编码基本思想:
1. 程序在处理外部数据时必须经过严格的合法性校验。编程人员在处理外部数据过程中必须时刻保持这种思维意识,不能做出任何外部数据符合预期的假设,外部数据必须经过严格判断后才能使用。编码人员必须在这种严酷的攻击环境下通过遵守这一原则保证程序的执行过程符合预期结果。
2. 尽量减少代码的攻击面。代码的实现应该尽量简单,避免与外部环境做多余的数据交互,过多的攻击面增加了被攻击的概率,尽量避免将程序内部的数据处理过程暴露到外部环境。
3. 通过防御性的编码策略来弥补潜在的编码人员的疏忽。由于外部环境的不确定性,以及编码人员的经验、习惯的差异,代码的执行过程很难达到完全符合预期设想的情况。因此在编码过程中必须采取防御性的策略,尽量缓解由于编码人员疏忽导致的缺陷。这些措施包括:
变量声明应该赋予初值
谨慎使用全局变量
禁用功能复杂、易用错的函数
禁用易用错的编译器/操作系统的机制
小心处理资源访问过程
不要改变操作系统的运行环境(创建临时文件、修改环境变量、创建进程等)
严格的错误处理
合理使用调试断言(ASSERT)
外部数据定义
文件(包括程序的配置文件)
注册表
网络
环境变量
命令行
用户输入(包括命令行、界面)
用户态数据(对于内核程序)
进程间通信(包括管道、消息、共享内存、socket、RPC等)
函数参数(对于API)
全局变量(在本函数内,其他线程会修改全局变量)
1 基础要求
1.1 变量
规则1.1.1:指针变量、表示资源描述符的变量、BOOL变量声明必须赋予初值
变量声明赋予初值,可以避免由于编程人员的疏忽导致的变量未初始化引用。
例外1: 对全局变量,静态变量,在编译阶段自动初始化为0或者等于NULL,不用在定义时强制初始化。
规则1.1.2:指向资源句柄或描述符的变量,在资源释放后立即赋予新值
资源释放后,对应的变量应该立即赋予新值,防止后续又被重新引用。如果释放语句刚好在变量作用域的最后一句,可以不进行赋值。
规则1.1.3:类的成员变量必须在构造函数中赋予初值
规则1.1.4:严禁对指针变量进行sizeof操作
编码人员往往由于粗心,将指针当做数组进行sizeof操作,导致实际的执行结果与预期不符。
下面的代码,buffer 和path分别是指针和数组,编码人员想对这2个内存进行清0操作,但由于编码人员的疏忽,第5行代码,将内存大小误写成了sizeof,与预期不符。
1. char *buffer = (char *)malloc(size);
2. char path[MAX_PATH] = {0};
3. ...
4. memset(path, 0, sizeof(path));
5. memset(buffer, 0, sizeof(buffer));
如果要判断当前的指针类型大小,请使用sizeof(char *)的方式。
建议1.1.1:尽量使用const
在变量声明前加const关键字,表示该变量不可被修改,这样就可以利用编译器进行类型检查,将代码的权限降到更低。
建议1.1.2:全局变量的访问如果涉及多个线程,需要考虑多线程竞争条件问题
应该尽可能减少全局变量的使用,如果多个线程会访问到该全局变量,则访问过程必须加锁。性能敏感的代码,请考虑采用原子操作或者无锁算法。
建议1.1.3:同一个函数内,局部变量所占用的空间不要过大
程序在运行期间,函数内的局部变量保存在栈中,栈的大小是有限的。如果申请过大的静态数组,可能导致出现运行出错。 建议在申请静态数组的时候,大小不超过0x1000。
1.2 断言(ASSERT)
断言是一种除错机制,用于验证代码是否符合编码人员的预期。编码人员在开发期间应该对函数的参数、代码中间执行结果合理地使用断言机制,确保程序的缺陷尽量在测试阶段被发现。断言被触发后,说明程序出现了不应该出现的严重错误,程序会立即提示错误,并终止执行。断言必须用宏进行定义,只在调试版本有效,最终发布版本不允许出现assert函数。
ASSERT宏定义参考:
1. #include <assert.h>
2. #ifdef DEBUG
3. #define ASSERT(f) assert(f)
4. #else
5. #define ASSERT(f) ((void)0)
6. #endif
在linux内核中定义ASSERT宏,可以采用如下方式:
1. #ifdef DEBUG
2. #define ASSERT(f) BUG_ON(!(f))
3. #else
4. #define ASSERT(f) ((void)0)
5. #endif
规则1.2.1:断言必须使用宏定义,禁止直接调用系统提供的assert()
断言只能在调试版使用,断言被触发后,程序会立即退出,因此严禁在正式发布版本使用断言,请通过编译选项进行控制。
规则1.2.2:运行时可能会导致的错误,严禁使用断言
规则1.2.3:严禁在断言内改变运行环境
在程序正式发布阶段,断言不会被编译进去,为了确保调试版和正式版的功能一致性,严禁在断言中使用任何赋值、修改变量、资源操作、内存申请等操作。
建议1.2.1:不要将多条语句放在同一个断言中
为了更加准确地发现错误的位置,每一条断言只校验一个条件。
1.3 函数
规则1.3.1:数组作为函数参数时,必须同时将其长度作为函数的参数
通过函数参数传递数组或一块内存进行写操作时,函数参数必须同时传递数组元素个数或所传递的内存块大小,否则函数在使用数组下标或访问内存偏移时,无法判断下标或偏移的合法范围,产生越界访问的漏洞。
固定长度的数组,也必须将数组大小作为函数的参数。
对于const char *类型的参数,它的长度是通过'\0'的位置计算出来,不需要传长度参数。
如果参数是char *,且参数作为写内存的缓冲区,那么必须传入其缓冲区长度。
如果函数仅对字符串中的特定字符进行一对一替换,或者删除字符串中的特定字符,这时对字符数组的访问不会超过原字符串边界,因此这类函数不需要传待修改的字符串长度。
例外1: 对于const struct *类型的数组入参,如果它的长度可以通过特定元素值判断结尾,那么可以不传递结构体数组的长度。
例外2: 对固定长度的数组,如果用数组的头地址作为子函数参数,由于性能原因,可以不用传递其长度。下例中,EtherAddrCopy()函数仅用于MAC地址的赋值,不会用于其他地方,且长度是可保证的,其拷贝使用的下标(或偏移)没有外部数据的影响。由于性能高度敏感,因此这里没有传入目的缓冲区dst的长度。
1. #define ETH_ALEN 6
2.
3. static const u8 ethReservedAddrBase[ETH_ALEN] = {...};
4. ...
5. void EtherAddrCopy(unsigned char *dst, const unsigned char *src)
6. {
7. dst[0] = src[0];
8. dst[1] = src[1];
9. dst[2] = src[2];
10. dst[3] = src[3];
11. dst[4] = src[4];
12. dst[5] = src[5];
13. }
14.
15. int AddDevice()
16. {
17. unsigned char mac[ETH_ALEN];
18. ...
19. EtherAddrCopy(mac, ethReservedAddrBase);
20. ...
21. }
规则1.3.2:严禁对公共接口API函数的参数进行ASSERT操作
对于设计成API的函数,必须对参数进行合法性判断,严禁在API实现过程中产生CRASH。对API函数的参数进行ASSERT操作是没有意义的。
公共接口API应当对输入参数进行代码检查。
规则1.3.3:不对内容进行修改的指针型参数,定义为const
建议1.3.1:谨慎使用不可重入函数
不可重入函数在多线程环境下其执行结果不能达到预期效果,需谨慎使用。
常见的不可重入函数包括:
rand, srand
getenv, getenv_s
strtok
strerror
asctime, ctime, localtime, gmtime
setlocale atomic_init
tmpnam
mbrtoc16, c16rtomb, mbrtoc32, c32rtomb
gethostbyaddr
gethostbyname
inet_ntoa
建议1.3.2:字符串或指针作为函数参数时,请检查参数是否为NULL
如果字符串或者指针作为函数参数,为了防止空指针引用错误,在引用前必须确保该参数不为NULL,如果上层调用者已经保证了该参数不可能为NULL,在调用本函数时,在函数开始处可以加ASSERT进行校验。
建议1.3.3:在函数的开始处对参数进行ASSERT操作(API除外)
在函数的开始处,对参数进行必要的ASSERT操作,可以在测试阶段有效地检验编码人员对代码设计上的预期。
1.4 循环
规则1.4.1:循环必须有退出条件
例外:
1、操作系统软件的IDLE线程,可能需要无限循环。
2、操作系统在不可恢复的错误中,为避免更多错误发生,进入指令无限循环。
3、嵌入式设备的操作系统或主流程,可能使用无限循环。
1.5 异常机制
规则1.5.1:禁用C++异常机制
严禁使用C++的异常机制,所有的错误都应该通过错误值在函数之间传递并做相应的判断, 而不应该通过异常机制进行错误处理。
编码人员必须完全掌控整个编码过程,建立攻击者思维,增强安全编码意识,主动把握有可能出错的环节。而使用C++异常机制进行错误处理,会削弱编码人员的安全意识。
异常机制会打乱程序的正常执行流程, 使程序结构更加复杂,原先申请的资源可能会得不到有效清理。
异常机制导致代码的复用性降低,使用了异常机制的代码,不能直接给不使用异常机制的代码复用。
异常机制在实现上依赖于编译器、操作系统、处理器,使用异常机制,导致程序执行性能降低。
在二进制层面,程序被加载后,异常处理函数增加了程序的被攻击面,攻击者可以通过覆盖异常处理函数地址,达到攻击的效果。
例外: 在接管C++语言本身抛出的异常(例如new失败、STL)、 第三方库(例如IDL)抛出的异常时,可以使用异常机制。
1.6 类
规则1.6.1:如果有构造函数,则必须有析构函数
析构函数的目的是为了在类释放的时候做相应的清理工作,以免编码人员忘记资源的清理。
规则1.6.2:构造函数内不能做任何有可能失败的操作
构造函数没有返回值,不能做错误判断,因此在构造函数内,不能做任何有可能失败的操作。
规则1.6.3:严禁在构造函数中创建线程
构造函数内仅作成员变量的初始化工作,其他的操作通过成员函数完成。
规则1.6.4:严禁出现 delete this操作
资源的申请和释放必须在同一逻辑层,谁申请,谁释放。
规则1.6.5:如果类的公共接口中返回类的私有数据地址,则必须加const类型
建议1.6.1:尽量避免定义public成员
类成员进行定义的时候,需要考虑类的功能,尽量减少对外接口的暴露。
1.7 安全退出
规则1.7.1:禁用atexit函数
atexit函数注册若干个有限的函数,当exit被调用后,自动调用由atexit事先注册的函数。当资源不再使用后,编码人员应该立即主动地进行清理,而不应该在最终程序退出后通过事先注册的例程被动地清理。
例外: 作为服务维测监控功能,为定位程序异常退出原因的模块,可以作为例外使用atexit()函数。
规则1.7.2:严禁调用kill、TerminateProcess函数终止其他进程
调用kill、TerminateProcess等函数强行终止其他进程(如kill -9),会导致其他进程的资源得不到清理。 对于进程间通信,应该主动发送一个停止命令,通知对方进程安全退出。当发送给对方进程退出信号后,在等待一定时间内如果对方进程仍然未退出,可以调用kill、TerminateProcess函数。
规则1.7.3:禁用pthread_exit、ExitThread函数
严禁在线程内主动终止自身线程,线程函数在执行完毕后会自动、安全地退出。主动终止自身线程的操作,不仅导致代码复用性变差,同时容易导致资源泄漏错误。
建议1.7.1:禁用exit、ExitProcess函数(main函数除外)
程序应该安全退出,除了main函数以外,禁止任何地方调用exit、ExitProcess函数退出进程。直接退出进程会导致代码的复用性降低,资源得不到有效地清理。程序应该通过错误值传递的机制进行错误处理。
建议1.7.2:禁用abort函数
abort会导致程序立即退出,资源得不到清理。
例外: 只有发生致命错误,程序无法继续执行的时候,在错误处理函数中使用abort退出程序。
2 字符串/数组操作
规则2.1:确保有足够的存储空间
部分字符串处理函数由于设计时安全考虑不足,或者存在一些隐含的目的缓冲区长度要求,容易被误用,导致缓冲区写溢出。典型函数如itoa,realpath。
规则2.2:对字符串进行存储操作,确保字符串有’\0’结束符
对字符串进行存储操作,必须确保字符串有’\0’结束符,否则在后续的调用strlen等操作中,可能会导致内存越界访问漏洞。
规则2.3:外部数据作为数组索引时必须确保在数组大小范围内
规则2.4:外部输入作为内存操作相关函数的复制长度时,需要校验其合法性
在调用内存操作相关的函数时(例如memcpy、memmove、memcpy_s、memmove_s等),如果复制长度外部可控,则必须校验其合法性,否则容易导致内存溢出。
规则2.5:调用格式化函数时,禁止format参数由外部可控
调用格式化函数时,如果format参数由外部可控,会造成字符串格式化漏洞。
这些格式化函数有:
格式化输出函数:xxxprintf;
格式化输入函数:xxxscanf;
格式化错误消息函数:err(),verr(),errx(),verrx(),warn(), vwarn(),warnx(),vwarnx(),error(),error_at_line();
格式化日志函数:syslog(),vsyslog()。
错误示例:
1. char *msg = GetMsg();
2. ...
3. printf(msg);
推荐做法:
1. char *msg = GetMsg();
2. ...
3. printf("%s\n", msg);
规则2.6:调用格式化函数时,format中参数的类型与个数必须与实际参数类型一致
格式化函数中,Format中的参数类型与个数与实际参数不一致,会导致读写的数据与期望不一致,造成输入输出数据错误,访问非法内存,写越界等问题。
例外:在格式化输出函数中,signed char,signed short可以用有符号整型格式%d输出,unsigned char, unsigned short类型参数可以用无符号整型格式如%u输出。