读软件开发安全之道:概念、设计与实施12不受信任的输入

1. 不受信任的输入

1.1. 不受信任的输入可能是编写安全代码的开发人员最关心的问题

  • 1.1.1. 最好将其理解为输入系统中的所有不受信任的输入

  • 1.1.2. 来自受信任的代码的输入可以提供格式正确的数据

1.2. 不受信任的输入是指那些不受你控制,并且可能被篡改的数据,包括所有进入系统但你不完全信任的数据

  • 1.2.1. 是你不应该信任的输入,而不是你错信了的输入

1.3. 任何来自外部并进入系统的数据都最好被认为是不受信任的

  • 1.3.1. 不受信任的输入令人担忧,因为它们代表了一种攻击向量,一种能够进入系统并制造麻烦的途径

1.4. 全球最大的不可信输入来源无疑是互联网

  • 1.4.1. 由于软件很难完全断开与互联网的连接,互联网几乎对所有系统都构成严重的威胁

2. 输入验证

2.1. 输入验证(或输入消毒)是一种防御性代码,它会对输入的内容施加限制,强制其遵守相应的规则

  • 2.1.1. 输入验证是一种很好的防御措施,因为它会将不受信任的输入缩减到应用程序可以安全处理的取值范围内

2.2. 不受信任的输入通常会穿越系统,并向下延伸到多个受信任的组件中

  • 2.2.1. 仅仅凭借你的代码会从受信任的代码中直接调用,并不能保证这些输入是可信的

2.3. 输入验证的基本工作是确保不受信任的输入能够符合设计规范,以便下游代码能处理格式正确的数据

2.4. 我们编写的几乎所有代码都只能在一个特定的限制内正常工作,它不能被用于极端情况

2.5. 缓解这种危险的一种简单方法就是对输入施加人为的限制,排除所有有问题的输入

  • 2.5.1. 限制当然不应该拒绝那些应该获得正确处理的输入

  • 2.5.2. 应该尽快对不受信任的输入进行验证,以便能够最大程度地降低不受约束的输入流向下游代码的风险

  • 2.5.3. 将输入验证视为应对不受信任的输入(特别是攻击面上的输入)的防御机制,但这并不意味着忽视其他的所有输入

2.6. 关键是一致性,因此一个好的模式是在负责处理传入数据的第一层代码中执行输入验证,然后将有效的输入交给更深层的业务逻辑,这些业务逻辑可以自信地认为所有输入都是有效的

2.7. 宁可在冗余的输入验证上犯错,也不要面临产生微小漏洞的风险

  • 2.7.1. 如果你不确定传入的数据是否经过了可靠验证,那么你需要自己执行输入验证来确保安全

3. 确定有效性

3.1. 输入验证一开始要确定什么是有效的

  • 3.1.1. 相当于预测未来所有有效的输入值,并找出合适的理由来禁止其余的输入值

3.2. 一旦指定了有效值的范围,就很容易确定适合代码的数据类型

  • 3.2.1. 通常有效的做法是对输入建立一个明确的限制,然后在实现中留出足够的余量,来确保正确地处理所有有效输入

  • 3.2.2. 余量是指当你要将一个文本字符串复制到4096字节的缓冲区中时,要将最大的有效长度设置为4000字节,这样你就有了一些余量

  • 3.2.3. 在C语言中,额外的空终止符导致缓冲区溢出1个字符是一个很容易犯的典型错误

4. 验证标准

4.1. 大多数的输入验证检查都包含几个标准,其中包括确保输入不会超过最大限制、数据以正确的格式传入,并且数据值在一个可接受的范围内

4.2. 检查值的大小是一种快速测试,主要是为了避免你的代码遭受DoS威胁,DoS威胁会导致你的应用程序在接受数兆字节的不受信任的输入后,变得运行缓慢甚至崩溃

4.3. 步骤

  • 4.3.1. 首先限制大小,这样你就不会浪费时间来尝试处理过大的输入

  • 4.3.2. 然后在解析之前确保输入的格式是正确的

  • 4.3.3. 最后检查结果值是否在可接受的范围内

4.4. 确定值的有效范围可能是最主观的选择,但重要的是要有具体的限制

  • 4.4.1. 范围的定义取决于数据类型

  • 4.4.2. 以字符而不是字节为单位指定字符串的最大长度,这样普通人才可以理解这个约束条件的含义

4.5. 根据某个目的来考虑输入的有效性会很有帮助

4.6. 选择一个对用户更友好的限制会更有意义

4.7. 输入验证的主要目的是确保无效输入不会通过验证

  • 4.7.1. 最简单的做法是拒绝无效输入

  • 4.7.2. 更宽容的选择是检测无效输入并将其修改为有效的形式

5. 拒绝无效输入

5.1. 拒绝不符合特定规则的输入,是最简单并且可以说是最安全的做法

5.2. 完全接受或拒绝是最干净妥当的做法,并且通常最容易做对

5.3. 每当人们直接提供输入(比如填写Web表格)时,最好能够提供足够的关于错误的信息,使他们能够更轻松地纠正错误并重新提交

  • 5.3.1. 暂停下来并要求数据源提供有效的输入,这是进行输入验证的保守做法,它也为普通用户提供了学习和适应的机会

5.4. 最佳实践

  • 5.4.1. 解释有效输入的构成,至少让阅读的人不必猜测并重试

  • 5.4.2. 一次标记多个错误,以便用户能够一次性更正并重新提交

  • 5.4.3. 当需要人们直接输入时,保持规则简单明了

  • 5.4.4. 将复杂的表格分成几个部分,并且每个部分都有一个单独的表格,这样人们可以看到事情的进展

5.5. 最佳方法是编写文档,精确地描述预期的输入格式和其他约束

5.6. 在专业运行系统的输入验证中,会完全拒绝整批输入,而不是尝试处理部分有效的数据子集,这种做法可能最合理,因为验证不通过就表示有些输入不符合规范

  • 5.6.1. 这样做允许纠正错误并再次提交完整的数据集,而无须梳理出哪些已处理,哪些未处理

6. 纠正无效输入

6.1. 完全接受有效输入并拒绝其他输入,这种做法既安全又简单,但绝对不是最好的做法

6.2. 如果我们不希望因为微小的错误而阻止人们继续的话,可以通过输入验证代码来尝试更正那些无效的输入,将它们转换为有效值,而不是直接拒绝输入

6.3. 根据用户的输入,以官方格式提供猜测出的相似地址,以供用户选择

6.4. 对于难度较高的验证需求来说,最好的办法是将输入设计得尽可能简单

6.5. 适当的输入验证需要谨慎的判断,但它也使软件系统更可靠、更安全

7. 字符串漏洞

7.1. 长度问题

  • 7.1.1. 长度是第一个挑战,因为字符串可能是无限长的

  • 7.1.2. 第一道防线是将不受信任的输入字符串的长度限制在合理的范围内

  • 7.1.3. 在分配缓冲区时,不要将字符数与字节长度混淆

7.2. Unicode问题

  • 7.2.1. Unicode是一个丰富的字符集,但这种丰富性的代价是隐藏的复杂性,并且这些复杂性会成为漏洞利用的沃土

  • 7.2.2. 大量字符编码可以将全世界的文本表示为字节,但大多数软件会将Unicode作为一种通用语

  • 7.2.2.1. Unicode标准(版本13.0)的长度刚超过1000页,指定了超过14万个字符、规范化算法、旧字符代码标准的兼容性,以及双向语言支持

  • 7.2.2.2. 几乎涵盖了世界上所有的书面语言,其编码超过了100万个代码点

  • 7.2.2.3. UTF-8是最常见的编码,同时还有UTF-7、UTF-16和UTF-32编码

  • 7.2.3. 排序规则(collation)取决于编码和语言,如果不关注它的话,就会产生意想不到的结果

  • 7.2.4. 在不需要支持不同的语言环境时,请考虑明确指定其运行的语言环境,而不是继承系统配置中的设置

  • 7.2.5. 安全性的底线是使用受信任的库来处理字符串,而不是直接对字节进行处理

  • 7.2.6. Unicode是对字符而不是字形(以何种视觉形式来呈现字符)进行编码

  • 7.2.7. 规范化文本的一种常用方法是将字符串中的字母转换为大写或小写

8. 注入攻击漏洞

8.1. 一种常见的软件技术能够构造一个字符串或数据结构(其中编码了要执行的操作)​,然后执行该字符串或数据结构来完成指定的任务

8.2. 如果攻击者可以改变操作的预期效果,那么这种影响可能会穿越信任边界,并由具有更高权限的软件执行

  • 8.2.1. 这就是对注入攻击的解释

8.3. 包括但不限于

  • 8.3.1. SQL语句

  • 8.3.1.1. SQL注入攻击

  • 8.3.2. 文件路径名称

  • 8.3.3. 正则表达式(作为一种DoS威胁)​

  • 8.3.4. XML数据(尤其是XXE声明)​

  • 8.3.5. shell命令

  • 8.3.6. 将字符串解释为代码(比如JavaScript的eval函数)​

  • 8.3.7. HTML和HTTP头部

8.4. 路径遍历

  • 8.4.1. 文件路径遍历是一个与注入攻击密切相关的常见漏洞

  • 8.4.2. 这种攻击不会破坏成对的引号​,而是会进入父目录,以获得对文件系统其他部分的意外访问

  • 8.4.3. 预防这类攻击最好的方法是对允许输入的字符集进行限制

  • 8.4.3.1. 仅由字母和数字构成的字符串就足以修复这个漏洞

  • 8.4.3.2. 它排除了从文件系统预期部分“逃逸”出去所需要的文件分隔符和父目录形式

  • 8.4.3.3. 只提供对于某个目录或其子目录中文件的访问,但绝对不提供对其他位置文件的访问

  • 8.4.3.4. base目录是一个可靠的路径,因为它不会涉及任何不受信任的输入:它的输入完全来自程序员控制下的值

8.5. 正则表达式

  • 8.5.1. 正则表达式(regex)具有高效、灵活和易于使用的特点,它提供了非常广泛的功能,并且可能是最常用来解析文本字符串的通用工具

  • 8.5.2. 在编码和执行上,正则表达式通常比临时代码更快且更可靠

  • 8.5.3. 正则表达式库会编译出状态表,状态表是一个解释器(有限状态机或类似的自动机制)​,能够执行字符串的匹配

  • 8.5.4. 缓解问题的最佳方法取决于具体的计算,但有几种通用的方法可以用来应对这些攻击

  • 8.5.4.1. 要避免让不受信任的输入影响到有可能崩溃的计算

  • 8.5.4.2. 在使用正则表达式的情况下,不要让不受信任的输入来定义正则表达式,尽可能避免回溯,并且限制使用正则表达式匹配的字符串的长度

  • 8.5.4.3. 要考虑最糟糕的计算,然后对其进行测试,以确保不会执行得过慢

8.6. XML的危险

  • 8.6.1. XML是表示结构化数据的最流行的方法之一,因为它功能强大且易于阅读

  • 8.6.2. 将不受信任的输入排除在你的代码所处理的任何XML之外

  • 8.6.3. 如果你不需要XML外部实体,就可以通过在输入中排除不受信任的输入,或者禁止处理这类声明来防止这类攻击

9. 缓解注入攻击

9.1. 输入验证始终是很好的第一道防线,但考虑到允许的输入中会包含的内容,仅此一项缓解措施不一定足够

9.2. 作为额外的防御层,要研究会形成的命令或语句的语法,并且要确保应用了所有必要的引用或转义,以确保不会出错

9.3. 通常可以在源代码中轻松扫描出使注入攻击成为风险的危险操作