您的位置:首页 > 编程语言

编写零漏洞代码所需的编码准则

InfoQ 2019-10-08 08:00 99 查看 https://www.geek-share.com/det

作者丨朴晋锈

译者丨才璐

盖房子时,如果负责基础工作的泥瓦匠手艺不精又没有竭尽全力,房屋质量就不可能过关。即使后期有技艺精湛的装潢布局使得建筑物的外观精美绝伦,根基没有打好也会成为“豆腐渣工程”。同理,决定软件系统质量的就是编码工作,这就要求负责编码的程序员具有过硬的基本功。

本文将和大家分析编写零漏洞代码所需的编码准则。

1 数组下标应从 0 开始

C 语言中的数组声明语句如下所示。

代码 P280 第一行

用该语句声明的数组具有如下形式。

代码 P280 第二段

此处需要注意,数组的第一个元素的下标为 0。也就是说,数组的第一个元素并不是 1 号元素,1 号元素实际上是数组的第二个元素。

程序员经常混淆这一点。这就导致为第五个元素赋值时,编写的语句形式如下所示。

代码 P280 第三行

这会导致原本应该赋给目标元素的值误赋予非目标元素,进而产生严重问题。实际上,为第五个元素赋值的正确语句如下所示。

代码 P281 第一行

指定数组下标时出现错误的原因主要在于,程序员基本功不够扎实。要想避免这些问题,就必须训练自己在指定数组下标时能够区分第一、第二、第三这种表示顺序的序数,以及一、二、三这种表示个数的基数。可以重读自己编写的代码,检查数组下标是否正确。

指定数组下标时出现的失误可能打乱整个数据集合。即应该赋给 1 号元素的值被赋给了 0 号元素,而 1 号元素仍保留原值。这与程序员的本意相背离,也很难定位引起这种数据混乱的根源所在。

这种从 1 开始对数组进行计数引起的错误又称大小差一(off-by-one)错误,顾名思义,就是少一个的意思。从 1 开始计数可能产生其他错误,比如无法处理数组的最后一个元素或引用无关数组,换言之,引用的数组可能始终是目标数组的下一个数组。

特别是在 while、for、do … while 循环语句中,这种大小差一错误更容易出现。

……中略……

计算数组所有元素总和

……中略……

根据注释可知,该 while 语句要实现的功能是计算数组元素总和,那么应该从 0 号元素开始计算。但 counter 的值是从 1 开始的,也就是说,是从 1 号元素开始计算的。这就是典型的大小差一错误。应该将该语句改写如下,counter 从 0 开始计数。

代码 P282 第二行

随着程序员编程经验日渐丰富,这种大小差一错误的出现率会逐渐降低。但编程菜鸟还是会经常出错,所以应当时刻保持警惕。特别是编写循环语句时,更要多加留意。

2 置换字符串时必须使用括号

首先分析经常被用作示例的宏函数。

代码 P282 第三行

从代码中可以看出,宏函数 SQRT(x) 可以计算 x 的平方值。从表面看这段代码并没有任何问题,运行如下语句后,可以得出 y 值为 100。

代码 P282 第四行

这是因为,该语句被置换为如下形式。

代码 P283 第一行

如果在宏函数中传递表达式作为参数,会出现什么结果呢?将下列表达式传递到宏函数,代码还能正常运行吗?

代码 P283 第二行

上述语句被置换为如下形式。

代码 P283 第三行

根据该表达式的运算符优先级,可以推测运算顺序如下,计算结果为 230。

代码 P283 第四行

我们预期的结果应该是 900,实际结果竟然是 230。

如何才能避免这种问题呢?如下所示,在定义宏函数时使用括号。

代码 P283 第五行

如上定义宏函数后,再用下列语句调用该函数。

代码 P283 第六行

可以得出正确答案 900,因为该语句被置换为如下形式。

代码 P283 第七行

3 文件必须有开就有关

我编写过一个处理主文件的程序,该主文件一次需要保管大约 3 万份数据。而且主文件的特性要求,允许多个程序拥有同时使用该文件的权限。

项目快要收尾时,我开始进行综合测试。此时,问题出现了。所有程序中,只有我编写的这个程序出现了问题。我从头开始检查程序代码,却没有发现任何异常。虽然在各种编译器配置下不断编译、执行,但界面始终显示“无法打开文件”的错误信息。

这实在令人百思不得其解。我耗费了整整一周的时间死抠代码。当然,整个项目也因此延期一周。1500 行代码让我眼花、恶心,最后身心交瘁,几乎要放弃。就在这时,我突然灵光乍现:“说不定,并不是程序有问题。”

如果不是程序有问题,那么是什么问题呢?我开始查看所有可能与该主文件有关的程序。除了我自己编写的程序外,还检查了其他所有人编写的程序。就这样,一周又过去了。

最终,我在一个非常小的程序里发现了问题,该程序是由一个菜鸟程序员编写的。这个程序打开相应主文件后,没有关闭文件。为了定位这个问题,我浪费了两周。项目整体也因此延期两周,最后不得不支付迟延产生的延期赔偿金。

提示:应该区分主文件和常见历史文件。以财务工作为例,每天收到的票据单据是历史文件,也是所有数据统计的基本材料。根据这些材料,可以统计每天累计的统计结果,生成资产平衡表和损益表,而这种文件就是主文件。

当时,软件工程尚未普及,很多程序员的基本功并不扎实。但我认为,即使是现在,总有一些地方仍然存在这种情况。当时那个程序代码有什么问题呢?虽然我不能回忆所有细节,但其基本形态应该如下所示。

示例 未关闭文件

……中略……

该代码试图按照如下方式处理。

示例 未关闭文件的程序伪代码

打开主文件。

无限循环

读取文件中的一行内容。

如果读入的行中没有任何数据,

通知“失败”并返回。

关闭文件。

这段代码的问题是什么呢?问题就在于,如果读入的行中没有任何数据,程序会立刻返回上层程序。即使没有读入任何数据,也应该将已经打开的文件关闭后再返回。否则,文件将始终处于打开状态,其他程序无法再次打开该文件。因此,那个菜鸟程序员一开始就应该编写如下伪代码。

示例 关闭文件的程序伪代码

打开主文件。

无限循环

读取文件中的一行内容。

如果读入的行中没有任何数据,

关闭文件。

通知“失败”并返回。

关闭文件。

只需在伪代码中增加一行“关闭文件”即可。只要那个菜鸟程序员多写这么一条语句,就可以防止项目延期两周,也就不必支付延期赔偿金。当初那个菜鸟程序员就应该根据上面的伪代码编写如下程序代码!

示例 关闭文件

就因为缺少这行代码,白白浪费了两周时间。

……中略……

fclose(masterFp); 这短短一行代码直接决定了项目成败。因此,菜鸟程序员要牢记这一点:绝对不能出现打开文件却没有关闭的情况!

4 不要无视编译器的警告错误

如果程序本身有语法错误,编译器会在编译过程中发现并提示其存在。

  • 致命错误(fatal error)

  • 警告错误(warning error)

所谓致命错误通常指,如果不修复错误,程序就无法运行。但偶尔也会有些致命错误并不影响程序运行,而通常会在运行过程中产生问题,所以最好在测试过程中查找。

此处着重讨论警告错误。这种错误可能在程序运行过程中并不会引起什么大问题,或者根本不会产生问题。修复这种问题就意味着修复全部程序漏洞。

这种情况下应该怎么做呢?我在大型项目中做单元测试时,曾遇到每次编译都会出现警告错误的情况。当时同事们查看编译器说明文档,想要找到这些错误出现的原因。编译器说明文档里确实记录了相应的修复方法。

但编译器开发人员和说明文档的作者都不是无所不能的神仙,他们也只是会犯错的普通人。无论我们如何按照文档方法修复程序,都没有办法阻止警告错误的出现。

于是,我们又尝试用其他编译器编译程序。使用其他编译器编译时,完全没有出现那些警告信息。因此,我们判断导致警告错误出现的罪魁祸首是编译器本身。

综合测试完成后,我们开始进行业务测试。在开展手工作业的同时,开始逐渐向用电子化系统实现全自动化工作模式转变。之后某天,电子化系统突然陷入瘫痪,结果当然引发一片混乱,订货公司甚至提出要完全废止电子化系统的引进。

我们整个团队开始通宵分析原始代码,但始终找不到问题所在。突然,大家提出一个想法:不妨试试以特别不规范的数据作为输入值。然后我们开始尝试输入那些日常工作中几乎不可能出现的数据,从非常小的数值到非常大的数值,甚至把一些完全不是数值的字符也输入为数值数据。经过种种可能的尝试,我们终于发现了问题。

由于输入了我们意料之外的非常大的数值,导致存储位置溢出、文件混乱,最后导致整个系统瘫痪。这种现象称为缓冲溢出或数据溢出。

我们最开始使用的编译器是如何预测并警告可能出现这种问题的呢?原因无从得知。也许连编译器开发人员和说明文档的作者也并不知情吧。但可以肯定的是,如果我们当时没有无视编译器的警告错误,就可以事先防止系统瘫痪。

掌握并在编码时防止运行时错误程序运行过程中出现的错误称为运行时错误,这种错误不同于编译错误和逻辑错误(程序流程漏洞引起的错误)。

编译错误主要是语法问题引发的,逻辑错误主要是程序逻辑或算法的设计缺陷引发的,而运行时错误则与运行时环境紧密相关。

例如,典型的运行时错误——栈溢出是由于操作系统限制栈的大小而产生的。换言之,只要计算机环境发生变化,栈的大小也会发生变化,这类问题有可能不再出现。只要认清运行时错误的种类,并在编写代码时注意避免这些错误即可。编译器说明文档详细记录了运行时错误,所以最好事先阅读。下面详细讲解其中两个最具代表性的运行时错误。这两种错误非常常见,只要能够避免二者的发生,就可以编写相当稳定的程序。

栈溢出计算机用栈这种数据结构管理临时存储空间。程序中使用的自动变量(大部分变量属于此)在被声明的同时就被保存到栈,而一旦脱离自动变量使用范围,就会被栈释放。查看以下代码。

示例 变量保存于栈

栈中保存变量 var1

栈中保存变量 var2

栈释放 var2

栈中保存变量 var3

栈依次释放 var3、var1

如上所示,变量在栈中随时保存或释放,所以没有必要将栈设为无限大。以实际生活中的某个仓库为例思考这个问题。如果该仓库中的货物随时进出,而并非不间断地堆积货物,那么只要保证仓库的大小略大于货物进出过程中的最大堆积量即可。与之类似,栈就是变量随时进出的场所,所以大小可以受限。

而问题正出在栈的大小受限这一点上。各操作系统都对栈的大小进行了限制,虽然现在这种限制程度略有放宽,甚至一部分操作系统允许用户自主控制并调整栈的大小,但处理大容量数据时仍可能发生栈溢出。例如,开始在栈中保存自动变量后,一旦存储的变量大小超出栈的大小,就会发生栈溢出。如果出现栈溢出,操作系统会强制终止程序。因为如果栈溢出后程序仍然继续运行,会侵犯栈之外的空间,并影响其他程序。

这种栈溢出常见于使用大数组或调用递归函数的情况。如下使用非常大的数组时,该数组占用了栈的大部分空间,可供其他自动变量存储的空间相对不足。我们将对这种情况进行单独说明。

示例 递归函数

……中略……

如上所示,使用不断调用函数本身的递归调用后,栈内不断累积 count 变量和 sum 变量。随着调用次数的增加,累积变量的数量也在增加,结果可能会在某一时刻超出栈的承受范围。如果无限调用递归函数,一定会出现栈溢出。递归调用次数越多,出现栈溢出的可能性越大。

因此,使用递归调用时,一定要细致检查出现栈溢出的可能性。特别是并未准确限制递归调用的次数,而递归只有在满足某个条件时才会终止的情况下,这种检查更为必要。如下示例所示。

示例 退出条件决定递归调用次数

……中略……

退出条件

该代码的退出条件是 count 与 sum 的值相等。如果 sum 的值较小,这段代码完全没有问题。也许程序员已经假设 sum 值不会太大。但假如 sum 值非常大,会出现什么呢?如果 sum 值是 50 000,count 是 1 呢?该函数就会被调用 50 000 次。在此期间,average 变量就会在栈中累积保存 50 000 次。不,准确地说,是累积保存到栈溢出为止。

像这样,如果递归调用的次数并不只是事先定好的数值,而是根据条件限制随时可能变化的数值,那么出现栈溢出的可能性非常高。这种情况下,最好在函数内部添加限制调用次数的语句。需要牢记,即使现在没有立刻出现多次调用,随着程序运行状况的变化,条件也可能发生变化。

除以 0

除以 0 错误的原理非常简单。如下所示,将某数除以 0 的情况会触发该错误。

代码 P292 第二段

没有程序员会故意用 0 除某数,但在非常复杂的代码中,可能出现除数偶然为 0 的情况。

假定需要编写一个处理输入值的程序,该程序的输入值最小为 1。程序员经常会忘记在程序中明确规定这一条件,一旦用户输入 0,并试图用该输入值作为除数进行除法运算,就会触发错误。

另一种情况常见于控制语句。试想,在 for 或 while 语句中,用计算循环次数的变量(计数器)作为除数,与其他变量值做除法运算。难道没有计数器为 0 的情况吗?倒序计数时又会怎样呢?如下示例所示。

示例 存在除以 0 的可能性

……中略……

存在除以 0 的可能性

……中略……

该代码中的 counter 的值迟早会变为 0,也就是说,迟早会出现除以 0 的情况。这种情况大多潜藏在复杂逻辑中,很难把握。这就要求程序员清醒认识到,除以 0 的情况随时可能出现,并为防范这种情况的出现而细致检查程序所有可能的运行情况。

5 用静态变量声明大数组

C 语言根据变量的生存周期、影响范围和存储位置的不同,将变量分为几类,如表 15-1 所示。

表 15-1 C 语言存储类型

以上就是存储类型。

除特别用 extern、static、register 声明的变量外,其他所有变量均为自动变量。因此,下列声明语句

代码 P294 第一行

与如下声明语句具有相同含义。

代码 P294 第二行

问题就在于这个自动变量。自动变量存储于以栈形态管理数据的内存。嵌入式系统等部分常用操作系统中,规定的栈较小。因此,如果一个程序用到的所有自动变量的总和超出栈的大小,就会触发栈溢出,进而导致程序异常终止。

变量最大通常不会超过 4 字节,而栈大约可以存放 18 000 个变量。一个程序使用的变量个数不会超过 18 000 个,最多使用几十个到几百个变量就足够了。

而数组则不同。需要处理大量数据时,通常会使用数组。数组包含的元素个数一般为几十个到几百个。随着数组维数的增加,数组包含的元素,即变量个数以乘方形式增加。以下面声明的三维数组为例。

代码 P295 第一行

该数组的元素个数为 100100100=1 000 000,每个元素都是 long int 型变量,占据 4 字节。因此,该数组的总体大小为 4 字节×1 000 000 个元素 =4 000 000 字节。此时,数组大小超出栈的大小,运行过程中,程序会因为发生栈溢出而异常终止。

该问题的解决方法很简单。将数组声明为静态变量而非自动变量即可。

代码 P295 第二行

提示:“用栈的形态管理”的意思是,像纸牌一样,在上方一层层整齐叠放。因此,最后声明的变量位于栈的最顶端,而释放变量时同样从最顶端开始,逐层释放。

如果声明为静态变量,那么数组的存放位置就是堆,而不是栈,也就不会出现栈溢出。并不只有数组大小大于栈时才能将数组声明为静态变量,即使数组小于栈,只要数组本身可能占据大量栈空间,就最好将数组声明为静态变量。这样可以保证有足够的空间,将数组之外的其他变量声明为自动变量并正常使用。

但管理静态变量与模块化原则相冲突,所以应该慎重对待是否声明静态变量的问题。

6 预留足够大的存储空间

我最开始接触计算机时,RAM 容量小得可怜。具体大小记不清了,应该在 64 KB 左右。当时,游戏开发人员使出浑身解数,只为尽可能高效利用 RAM。有人试图用最短的机器语言编写最高效的程序,还有人用汇编语言编程的同时,也会特意用机器语言编写其中“最耗”内存的部分。当时甚至还有“一行代码”比赛,内容就是看谁能够用一行代码编写占内存最少、最酷炫的程序。

随着时间的推移,内存价格逐渐降低,容量也在迅速扩大,人们不必再竭尽全力节约内存。例如,C 语言中用于存储字符串的数组长度没有必要与字符串长度相吻合。人们完全可以将数组容量定义得足够大,更准确地说,应该是最好那么做。因为一旦字符串长度超出内存大小,程序就会产生问题。如果存储的字符串超出指定内存位置,数据之间可能发生冲突,由此导致程序安全大打折扣。

代码 P296

数组定义如上所示,在该数组 inputString 存入长度超过 80 个字符的字符串时,会出现各种问题。从根本上解决问题的方法是,在“有效性检查”的过程中,检查输入字符串是否超出对应内存空间的大小;另一种方法是,确保定义的内存空间远大于预期的用户输入值。不可否认,随着内存的增大,处理速度会逐渐变慢,但一般大小的内存引起的速度降低并不会特别明显。

例如,用户一般输入的字符串长度为 80 个字符,那么定义时就应该预留 2~3 倍甚至更多字符串存储空间。

预留的存储空间大小是预期输入长度的 10 倍。

这种做法究竟是不是资源浪费呢?答案只有在出现异常情况时才能真正体现。一旦用户违背了“只能输入 80 个字符”的规定,连续几秒内始终按着键盘,那么上述代码就能发挥自己的真实价值。虽然是老生常谈,但我仍要强调,首先要验证输入值的有效性,即首先检查输入字符串的长度、内容是否在有效范围内。并且为了防范省略或遗漏检查过程的情况,应该考虑事先预留足够大的存储空间。

7 注意信息交换引发的涌现效果

我听说日本有一位研究机器人的科学家,他用简单的人工智能程序开发了一些小型机器人,这些机器人之间可以进行信息交换。有一天,突然发生了一件科学家始料未及的怪事:机器人纷纷离家出走了。科学家不仅没有预料到会出现这种事情,而且根据自己编写的程序逻辑看,这种事情也完全不可能发生。但事实摆在眼前,问题是什么呢?程序有问题吗?可能是,也可能不是。

我偶然听到这个传闻时,脑海中出现了一个想法。当时我正在某个公司参与编写安全软件相关资料,所以萌生的想法与信息安全相关:程序单元层面可能存在完全无法解决的问题。换言之,无论怎样强化个别软件的安全级别,无论怎样声称单个程序绝对不会出现问题,系统层面总可能出现意想不到的状况。

这与复杂系统理论中的一个现象非常类似。程序单元之间进行信息交换的过程中,可能出现信息丢失,也可能新增冗余信息,这一点多少可以预见,并成为广为人知的安全问题。这种问题可以通过在各程序单元中检查信息有效性得到解决,也就是用检查输入数据长度的方式校验。

但即使如此,系统层面仍可能出现问题。因为程序单元之间进行信息交换的过程中,可能引发涌现性。涌现性指的是能够引起意想不到的效果的性质,是复杂系统相关研究领域非常常见的词汇。程序单元之间通过交换包括数据、文字消息在内的信息形式联系在一起,之后,其组成的系统单元或上层系统都具有与复杂系统相似的特性。虽然如果现在主张“系统单元可以视为复杂系统,并且可能产生涌现性”,会有很多人反驳说这是无稽之谈,但至少从我目前的经验看,这是可能的。

那么如何预防这种涌现性现象引起的问题呢?目前还没有可行的对策,但存在一种沿用至今的解决方式,即系统层面的综合测试方法。例如,将包含 10 个相互联系的程序单元称为综合系统,首先在这一层级进行综合测试;然后将这种系统聚集为整个软件系统,在该层级再次进行综合测试;之后将其应用于实际业务,在人工交互阶段再次进行严格的综合测试。以这样严格的综合测试为基础,可以在一定程度上发现并预防涌现性现象,但这要求在测试过程中投入与软件开发过程同样多的资源。

本文内容来自作者图书作品《这样编码才规范:128 个编码好习惯》

点个在看少个 bug 👇

标签: 
相关文章推荐