内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。
目录
防御式编程
常用规则
-
防御式编程
在防御性编程的大框架之下,有许多常识性的规则。人们在想到防御性编程的时候,通常都会想到“断言”,这没有错。我们将在后面对此进行讨论。但是,还是有一些简单的编程习惯可以极大地提高代码的安全性。
常用规则
-
使用好的编码风格和合理的设计我们可以通过采用良好的编程风格,来防范大多数编码错误。这与本篇的其他章节自然地吻合。很多简单的事,如选用有意义的变量名,或者审慎地使用括号,都可以使编码变得更加清晰明了,并减少缺陷出现的可能性。
同样地,在投入到编码工作中之前,先考虑大体的设计方案,这也非常关键。“最好的计算机程序的文本是结构清晰的。”(见参考书目Kernighan Plaugher 78)从实现一套清晰的API、一个逻辑系统结构以及一些定义良好的组件角色与责任开始入手,将使你避免以后处处头疼的局面。
-
不要仓促地编写代码闪电式的编程太常见了。使用这种编程方式的程序员会很快地开发出一个函数,马上把这个函数交给编译器来检查语法,接着运行一遍看看能不能用,然后就进入下一个任务。这种方式充满了危险。
相反,在写每一行时都三思而后行。可能会出现什么样的错误?你是否已经考虑了所有可能出现的逻辑分支?放慢速度,有条不紊的编程虽然看上去很平凡,但这的确是减少缺陷的好办法。
关键概念 欲速则不达。每敲一个字,都要想清楚你要输入的是什么。
在C语言中,有一个会使追求速度的程序员犯错的陷阱,即将“==”错误地输入为“=”。前者为相等关系测试,而后者则是变量赋值。如果你的编译器功能不全(或者关闭了警告功能),你就不会得到相关提示,也就无从得知自己输入了不该输入的东西。
一定要在完成与一个代码段相关的所有任务之后,再进入下一个环节。例如,如果你决定先编写主体部分,再加入错误检查和处理,那么一定要确保这两项工作的完成都遵循章法。如果你要推迟错误检查的编写,而直接开始编写超过三个代码段的主体部分,你一定要慎之又慎。你也许真的想随后再回来编写错误检查,但却一而再再而三地向后推迟,这期间你可能会忘记很多上下文,使得接下来的工作更加耗时和琐碎。(当然,到时候你还要面临一些人为设置的最后截止日期。)
遵循章法是一种习惯,需要牢记于心并切实贯彻。如果你不立即做正确的事,那么将来你很可能也不会再去做正确的事。现在就行动,不要等到撒哈拉沙漠下雨了才行动。晚做不如早做,因为将来再做将需要遵循更多的章法。
-
不要相信任何人妈妈曾告诉过你,不要和陌生人说话。不幸的是,要想开发一个好的软件,就需要更加愤世嫉俗,对人的天性更加不信任。即便是没有恶意的代码用户,也可能会给你的程序带来麻烦。防御意味着不能相信任何人。
下面这些情况可能是给你带来麻烦的原因:
你甚至可能会在编写一个函数时犯下愚蠢的错误,或者错误地使用三年前编写的代码,因为你忘记了这些代码究竟是怎样运行的。不要设想所有的一切都运行良好,或者所有的代码都会正确地运行。在你的程序各处都添加安全检查。时刻注意弱点,用更多的防御性代码防止弱点的出现。
不要相信任何人毫无疑问,任何人(包括你自己)都可能把缺陷引入你的程序逻辑当中。用怀疑的眼光审视所有的输入和所有的结果,直到你能证明它们是正确的时为止。
- 真正的用户 意外地提供了假的输入,或者错误地操作了程序;
- 恶意的用户 故意造成不好的程序行为;
- 客户端代码 使用错误的参数调用了你的函数,或者提供了不一致的输入;
- 运行环境 没有为程序提供足够的服务;
- 外部程序库 运行失误,不遵从你所依赖的接口协议。
-
-
编码的目标是清晰,而不是简洁如果要你从简洁(但是有可能让人困惑)的代码和清晰(但是有可能比较冗长)的代码中选择,一定要选那些看上去和预期相符合的代码,即使它不太优雅。例如,将复杂的代数运算拆分为一系列单独的语句,使逻辑更清晰。
想一想,谁会是你的代码的读者。这些代码也许需要一位初级程序员来进行维护,如果他不能理解代码的逻辑,那么他肯定会犯一些错误。复杂的结构或不常用的语言技巧可以证明你在运算符优先级方面渊博的知识,但是这些实际上会扼杀代码的可维护性。请保持代码简单。
不能维护的代码是不安全的。举一个极端的例子,过于复杂的表达式会使编译器生成错误的代码,许多编译器优化的错误就是因此而造成的。
简单就是一种美。不要让你的代码过于复杂。
-
不要让任何人做他们不该做的修补工作内部的事情就应该留在内部。私人的东西就应该用锁和钥匙保管起来。不要把你的代码初稿示于众人。不管你多么礼貌地恳求,只要你稍不注意,别人就会篡改你的数据,然后自以为是地试着调用“仅用于执行”的例行程序。不要让他们这样做。
-
编译时打开所有警告开关大多数语言的编译器都会在你“伤了它们感情的时候”给出一大堆错误信息。当这些编译器碰到潜在的有缺陷代码时(如在赋值之前使用C或C++变量)[3],它们也会给出各种各样的警告。通常情况下,这些警告可以有选择地启用或禁用。
如果你的代码中充满了危险的构造,你将会得到数页的警告信息。糟糕的是,通常的反应是禁用编译器的警告功能,或者干脆不理会这些信息。这两种做法都不可取。
在任何情况下都要打开你的编译器的警告功能。如果你的代码产生了任何的警告信息,立即修正代码,让编译器的报错声停下来。在启用了警告功能之后,不要对不能安静地完成编译的代码感到满意。警告的出现总是有原因的。即使你认为某个警告无关紧要,也不要置之不理。否则,总有一天这个警告会隐藏一个确实重要的警告。
编译器的警告可以捕捉到许多愚蠢的编码错误。在任何情况下都启用它们。确保你的代码可以安安静静地完成编译。
-
使用静态分析工具编辑器警告是对代码的一次有限的静态分析(即在程序运行之前执行的代码检查)的结果。
还有许多独立的静态分析工具可供使用,如用于C语言的lint(以及更多新出的衍生工具)和用于.NET汇编程序的FxCop。你的日常编程工作,应该包括使用这些工具来检查你的代码。它们会比你的编译器挑出更多的错误。
-
使用安全的数据结构如果你做不到,那么就安全地使用危险的数据结构。
最常见的安全隐患大概是由缓冲溢出引起的。缓冲溢出是由于不正确地使用固定大小的数据结构而造成的。如果你的代码在没有检查一个缓冲的大小之前就写入这个缓冲,那么写入的内容总是有可能会超过缓冲的末尾的。
这种情况很容易出现,如下面这一小段C语言代码所示:
char *unsafe_copy(const char *source)
{
char *buffer = new char[10];
strcpy(buffer, source);
return buffer;
}
如果source中数据的长度超过10个字符,它的副本就会超出buffer所保留内存的末尾。随后,任何事都可能会发生。数据出错是最好情况下的结果——一些其他数据结构的内容会被覆盖。而在最坏的情况下,恶意用户会利用这个简单的错误,把可执行代码加入到程序堆栈中,并使用它来任意运行他自己的程序,从而劫持了计算机。这类缺陷常常被系统黑客所利用,后果极其严重。
避免由于这些隐患而受到攻击其实很简单:不要编写这样的糟糕代码!使用更安全的、不允许破坏程序的数据结构——使用类似C++的string类的托管缓冲。或者
对不安全的数据类型系统地使用安全的操作。通过把strcpy更换为有大小限制的字符串复制操作strncpy,就可以使上面的C代码段得到保护。
char *safer_copy(const char *source)
{
char *buffer = new char[10];
strncpy(buffer, source, 10);
return buffer;
}
-
检查所有的返回值如果一个函数返回一个值,它这样做肯定是有理由的。检查这个返回值。如果返回值是一个错误代码,你就必须辨别这个代码并处理所有的错误。不要让错误悄无声息地侵入你的程序;忍受错误会导致不可预知的行为。
这既适用于用户自定义的函数,也适用于标准库函数。你会发现:大多数难以察觉的错误都是因为程序员没有检查返回值而出现的。不要忘记,某些函数会通过不同的机制(例如,标准C库的errno)返回错误。不论何时,都要在适当的级别上捕获和处理相应的异常。
-
审慎地处理内存(和其他宝贵的资源)对于在执行期间所获取的任何资源,必须彻底释放。内存是这类资源最常提到的一个例子,但并不是唯一的一个。文件和线程锁也是我们必须小心使用的宝贵资源。做一个好的“管家”。
不要因为觉得操作系统会在你的程序退出时清除程序,就不注意关闭文件或释放内存。对于你的代码还会执行多长时间,是否会耗尽所有的文件句柄或占用所有的内存,其实你一无所知。你甚至不能肯定操作系统是否会完全释放你的资源,有的操作系统就不是这样的。
防御式编程的目的
目的
我们编程最终的目标是让产品能平稳运行,而且要”不以人的意志为转移“式地平稳运行。
这其中包括两点:
- 产品代码要逻辑正确、完备;
- 代码能够让人读而知其义,能尽量避免他人犯错;
防御式编程就是在做到 1 后,再把第 2 条做好。
在这里,一定要看清楚,做到1后再做2。 有些软件管理可能过分强调防御,在功能,逻辑都还没有完备时,大搞特搞防御,其实并不可取。其实,我们应该在功能雏形做好后,再防御。毕竟过早防御会使得代码过分臃肿。
在实际编程中,主要使用断言(assert) 来检测程序中可能存在的问题,它是一种高级的异常处理,当程序都正常运行时候,断言的存在对程序没有影响.
示例:
FILE *fp = fopen("main.cpp", "rb");
const int MAX_BUF = 8;
char *buf = new char[MAX_BUF];
int N = 99;
int i_to_read = 1024;
assert(MAX_BUF - i_to_read > 0);
fread(buf, 1, i_to_read, fp);
assert(N == 99);
fclose(fp);
delete[] buf;
assert
那一行就是防止别人犯错,并把错误尽早地暴露出来的防御代码。
防御式编程实践
接下来,我们介绍下,编程实践中常见问题以及防御
防内存越界
assert(MAX_BUF - i_to_read > 0);
fread(buf, 1, i_to_read, fp);
防数组越界
int get_arr_by_idx(int *arr, int len, int idx);
int get_arr_by_idx(int *arr, int len, int idx)
{
assert(idx <= len - 1);
return arr[idx];
}
防内存越界
char buf[9] = 0;
int sz_to_set = 4;
assert(sz_to_set >= 0);
memset(buf, 0xAF, sz_to_set);
防被除数为零
double t = 9.9f;
for (int i = -2; i < 2; i++)
{
assert(i != 0);
cout << t / i << endl;
}
野指针
char *buf = NULL;
assert(NULL != buf);
printf(buf);
strlen 的实现
在之前的学习中,我们曾经使用过strlen
函数。在这一节课我们将模拟实现strlen
函数。
这里我们已经给出了基本的代码框架,我们只需要实现strlen
函数即可,首先我们先定义strlen
函数,返回值类型为int
,形参为字符指针
。因为是判断字符串长度,所以字符串是不变的,所以用const
修饰,使其更加安全。
请在main
函数上面写下:
int strlen(const char* s) {
}
const
源字符串参数用const
修饰,防止修改源字符串。
const
是一个 C 语言(ANSI C)的关键字,具有着举足轻重的地位。它限定一个变量不允许被改变,产生静态作用。使用const
在一定程度上可以提高程序的安全性和可靠性。另外,在观看别人代码的时候,清晰理解const
所起的作用,对理解对方的程序也有一定帮助。
#include <stdio.h>
int main() {
char string[1010];
scanf("%s", string);
printf("%d\n", strlen(string));
return 0;
}
接下来我们要对输入进行检验,确保定义的指针不能为空,所以要使用assert
进行断言。
请在strlen
函数中写下:
assert(s != NULL);
因为调用了assert
函数,所以我们也需要导入相应的头文件,请在#include <stdio.h>
下面写下:
#include <assert.h>
assert
assert
宏的原型定义在<assert.h>
中,其作用是如果它的条件返回错误,则终止程序执行。
编写代码时,我们总是会做出一些假设,断言就是用于在代码中捕捉这些假设,可以将断言看作是异常处理的一种高级形式。断言表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真。可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言,而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。
strlen
所作的仅仅是一个计数器的工作,它从内存的某个位置(可以是字符串开头,中间某个位置,甚至是某个不确定的内存区域)开始扫描,直到碰到第一个字符串结束符'\0'
为止,然后返回计数器值(长度不包含'\0'
)。即strlen
函数计算的是不算结束标志符'\0'
在内的字符串的长度。
请在assert(s != NULL);
下面写下:
int len = 0;
while (*s != '\0') {
++len;
++s;
}
return len;
点击 运行 测试一下程序,看看会有怎样的结果?
你可以输入:
jisuanke
当然你也可以输入其他字符串。
#include <stdio.h>
#include <assert.h>
int strlen(const char* s) {
assert( s != NULL);
int len = 0;
while (*s != '\0') {
++len;
++s;
}
return len;
}
int main() {
char string[1010];
scanf("%s", string);
printf("%d\n", strlen(string));
return 0;
}
strcpy 的实现
在之前的学习中,我们曾经使用过strcpy
函数。在这一节课我们将模拟实现strcpy
函数。
这里我们已经给出了基本的代码框架,我们只需要实现strcpy
函数即可,首先我们先定义strcpy
函数,返回值类型为字符指针
,形参为字符指针
。因为src 字符串是不变的所以用const
修饰,dst 字符串是需要被修改的,所以这里不能使用const
修饰。
char* strcpy(char* dst, const char* src) {
}
接下来我们要对输入进行检验,确保定义的指针不能为空,所以要使用assert
进行断言。
请在strcpy
函数中写下:
assert(dst != NULL && src != NULL);
空指针检查
-
不检查指针的有效性,说明答题者不注重代码的健壮性。
-
检查指针的有效性时使用 assert(!dst && !src);
char*
转换为bool
即是类型隐式转换,这种功能虽然灵活,但更多的是导致出错概率增大和维护成本升高。 -
检查指针的有效性时使用 assert(dst != 0 && src != 0); 直接使用常量(如本例中的 00 )会减少程序的可维护性。而使用
NULL
代替 00,如果出现拼写错误,编译器就会检查出来。
定义返回变量 ret,因为后面的代码中,我们将会修改 dst,所以我们使用 ret 保存原始的 strdst 值。
请接着上面的代码写下:
char* ret = dst;
假如考虑 dst 和 src 内存重叠的情况,strcpy
该怎么实现。
所谓重叠,就是 src 未处理的部分已经被 dst 给覆盖了,只有一种情况: src <= dst <= src + strlen(src)
首先我们要获取 src 的长度。请接着上面的代码写下:
int len = strlen(src);
如果我们不考虑内存重叠问题直接 while ((*dst++ = *src++) != '\0'); 即可。但是从工程安全角度考虑,这样写是不对。
如果内存重叠,从高地址开始复制。
请接着上面的代码写下:
if (dst >= src && dst <= src + len - 1) {
dst = dst + len - 1;
src = src + len - 1;
while (len--) {
*dst-- = *src--;
}
}
否则正常情况,从低地址开始复制。
请接着上面的代码
else {
while (len--) {
*dst++ = *src++;
}
}
这个时候我们已经完成了strcpy
,把 dst 的最后一位赋值为 '\0'
,然后返回 ret 即可。
请接着上面的代码写下:
*dst = '\0';
return ret;
点击 运行 测试一下程序,看看会有怎样的结果?
你可以输入:
suantoujun huayemei
当然你也可以输入其他字符串。
#include <stdio.h>
#include <assert.h>
char* strcpy(char* dst, const char* src) {
assert(dst != NULL && src != NULL);
char* ret = dst;
int len = strlen(src);
if (dst >= src && dst <= src + len - 1) {
dst = dst +len - 1;
src = src +len - 1;
while (len--) {
*dst-- = *src--;
}
}
else {
while (len--) {
*dst++ = *src++;
}
}
*dst = '\0';
return ret;
}
int main() {
char a[1010], b[1010];
scanf("%s %s",a, b);
printf("原始字符串:%s\n", a);
strcpy(a, b);
printf("复制后的字符串:%s\n", a);
return 0;
}
strcmp 的实现
在之前的学习中,我们曾经使用过strcpy
函数。在这一节课我们将模拟实现strcpy
函数。
设这两个字符串为 str1,str2,
若 str1=str2,则返回零;
若 str1<str2,则返回负数;
若 str1>str2,则返回正数。
这里我们已经给出了基本的代码框架,我们只需要实现strcmp
函数即可,首先我们先定义strcmp
函数,返回值类型为int
,形参为字符指针
。
int strcmp(const char* str1,const char* str2) {
}
然后逐个字符比较,直到到字符串结尾,或者字符串不相同结束。千万不要忘记判断指针为空的情况。
请在strcmp
函数中写下:
while(*str1 == *str2) {
assert((str1 != NULL) && (str2 != NULL));
if(*str1 == '\0') {
return 0;
}
str1++;
str2++;
}
这里我们要返回哪个字符串大,这里我们可以巧妙的返回当前两个字符的差即可。
请接着上面的代码写下:
return *str1 - *str2;
点击 运行 测试一下程序,看看会有怎样的结果?
你可以输入:
suantoujun huayemei
当然你也可以输入其他字符串,最好把这三种情况都测试测试。
#include <stdio.h>
#include <assert.h>
int strcmp(const char* str1, const char* str2) {
while(*str1 == *str2) {
assert((str1 != NULL) && (str2 != NULL));
if(*str1 == '\0') {
return 0;
}
str1++;
str2++;
}
return *str1 - *str2;
}
int main() {
char a[1010], b[1010];
scanf("%s%s", a, b);
int x = strcmp(a, b);
if (x < 0) {
printf("a < b\n");
} else if (x == 0) {
printf("a == b\n");
} else {
printf("a > b\n");
}
return 0;
}
strcat 的实现
在之前的学习中,我们曾经使用过strcat
函数。在这一节课我们将模拟实现strcpy
函数。
这里我们已经给出了基本的代码框架,我们只需要实现strcat
函数即可,首先我们先定义strcat
函数,返回值类型为字符指针
,形参为字符指针
。
char* strcat(char* dst, const char* src) {
}
接下来我们要对输入进行检验,确保定义的指针不能为空,所以要使用assert
进行断言。
请在strcat
函数中写下:
assert(dst != NULL && src != NULL);
接下来我们要找到 dst 结束的位置('\0'
),然后开始赋值。
因为我们需要修改 dst 的值,所以我们需要定义变量 ret 用来保存 dst。
请接着上面的代码写下:
char* ret = dst;
while(*dst != '\0') {
dst++;
}
接下来我们就可以把字符串 src 连接到 dst 字符串上,直到 src 字符串的结束。
请接着上面的代码写下:
while((*dst++ = *src++) != '\0');
点击 运行 测试一下程序,看看会有怎样的结果?
你可以输入:
suantoujun huayemei
当然你也可以输入其他字符串。
#include <stdio.h>
#include <assert.h>
char* strcat(char* dst, const char* src) {
assert(dst != NULL && src != NULL);
char* ret = dst;
while(*dst != '\0') {
dst++;
}
while((*dst++ = *src++) != '\0');
*dst = '\0';
return ret;
}
int main() {
char a[1010], b[1010];
scanf("%s%s", a, b);
strcat(a, b);
printf("%s\n", a);
return 0;
}
strchr 的实现
在之前的学习中,我们曾经使用过strcat
函数。在这一节课我们将模拟实现strcpy
函数。
这里我们已经给出了基本的代码框架,我们只需要实现strcat
函数即可,首先我们先定义strcat
函数,返回值类型为字符指针
,形参一个为字符指针
,令一个为字符类型
。
char* my_strchr(const char *s, char c) {
}
接下来我们要对输入进行检验,确保定义的指针不能为空,所以要使用assert
进行断言。
请在strchr
函数中写下:
assert(s != NULL);
接下来我们要查找字符串 s 中的字符 c,直到找到字符 c 或者字符串结束。
请接着上面的代码写下:
while(*s != '\0' && *s != c) {
++s;
}
接下来我们要查找字符串 s 中的字符 c,直到找到字符 c 或者字符串结束。
最后我们需要判断是否找到了字符 c,如果找到返回 s,否则返回 NULL
。
请接着上面的代码写下:
return *s == c ? s : NULL;
点击 运行 测试一下程序,看看会有怎样的结果?
你可以输入:
suantoujun t
当然你也可以输入其他字符串。
#include <stdio.h>
#include <assert.h>
char* my_strchr(const char *s, char c) {
assert(s != NULL);
while(*s != '\0' && *s != c) {
++s;
}
return *s == c ? s : NULL;
}
int main() {
char string[1010], c;
scanf("%s %c", string, &c);
char* ptr = strchr(string, c);
if(ptr != NULL) {
printf("The character %c is at position: %s\n", c, ptr);
} else {
printf("The character was not found\n");
}
return 0;
}
C 语言内置函数
请将下面的内置函数与它对应的用法配对。
C 语言内置函数
关于 C 语言内置函数,请从列出的选项中选出说法正确的选项。
密码的安全设计