代码安全审计之道
代码审计是每个安全研究员都应该掌握的技能。但是网上对于代码审计的介绍文章却比较匮乏。因此本文一方面作为 The Art of Software Security Assessment 一书的阅读笔记,另一方面也结合自己日常工作的经验总结,希望能对国内的安全研究员有个抛砖引玉的帮助。
前言
对于许多安全研究人员而言,Fuzzing 是一个重要的漏洞挖掘方法,但每个方法都有其局限性。首先,为了有效地进行 Fuzz,需要准确找到攻击面并在此基础上进行自动化测试,寻找攻击面的过程离不开代码审计;其次,在自动化测试找到 crash 之后,分析和写报告的过程需要人工审计理解代码逻辑和漏洞的 root cause;最后,对于 Java 或者 Go 这类内存安全的语言而言,Fuzzing 的应用场景更是受限,只能通过代码审计去发现复杂的逻辑设计问题。
本文算是对代码审计的一个方法论式的总结,对具体代码的所属语言并无限制,可以是 C/C++,也可以是 Java、PHP。对于具体语言所独有的 Source、Sink 以及自动化分析辅助工具不在本文范围,感兴趣的朋友可以关注后续的代码安全审计之术篇。
谋定而后动
孙子兵法有云: 谋定而后动。在现代管理学中也有一句名言: “If you can’t measure it, you can’t manage it.”
可见凡事在开始之前,都需要先确定好自己的目标和计划,这样才能确保自己不会偏离方向或者误入歧途。 对于安全审计亦是如此。工作时经常会发现一些同事一头扎进某个项目的代码中,眨眼就是几个月,一问进展如何不知,还有多久不知,有何计划也不知,代码审计全凭感觉,有无收获全靠运气。……
为了避免这种情况,在开始前我们不妨先问一下自己的目标是什么。这乍看起来是个很简单的问题,但对于不同的场景可能有不同的回答。通常安全研究人员的目标是在最短的时间内找到最有价值的漏洞;对于商业安全顾问而言,其目标是在项目预算允许的范围内尽可能达到更高的审计覆盖率;另外对于开发者或者安全架构师而言,则会花费更长的时间周期进行内部安全评审。
对于商业环境中的代码安全审计,通常甲方更关心的是容易检测的安全问题,而不甚考虑其复杂性和技术难度。因为他们的目标是避免漏洞被公开或者利用造成负面舆情影响。如果此时你将时间都花在了某些复杂问题中而忽略了浅显的漏洞,那显然是拿不到甲方好评的。将时间花费在没有 “技术含量” 的简单漏洞中对于很多安全从业人员而言似乎有点羞耻,但却是一个合理的商业决定。
这并不是说我们应该忽略复杂的安全问题而只去看简单的漏洞,关键是要提前明确自己的审计目标。对于一项审计任务要分成两面看,一面是审计人员自己的时间投入成本,另一面是待审计应用的复杂度。二者结合起来才能有效评估此次审计的产出预期。
待审计应用根据访问权限划分一般有以下几种情况:
- 仅源码: 我们仅拥有目标的源代码,通常不包含完整的编译和测试环境,而且由于缺乏必须的关键依赖组件,往往无法构建出可运行的程序。这种情况一般只能使用静态分析的方式去进行审计;
- 仅二进制程序: 我们仅拥有目标应用的二进制文件,比如 APK、EXE、jar 包或者 IoT 的系统固件等。这种情况下通常通过动态分析以及逆向工程的方式进行审计;
- 有源码及二进制程序: 我们既能够访问目标应用的源代码,也拥有一个可运行的二进制程序,这给安全审计提供了最为有利的访问权限,通常目标是开源软件,包含了完整的构建环境和依赖。
- 完全黑盒: 我们既没有目标的源代码,也没有可运行的二进制程序,因此只能通过外部接口去进行盲测。这在 Web 应用中较为常见。
本文主要针对的是有源码情况下的代码安全审计,当然源码审计中使用的一些策略和方法同样适用于其他类型的应用。在能够访问源码的情况下,一个明显的计量标准是通过代码行数去评估工作量,尽管这个指标并不能完美代表应用的复杂度,毕竟 1000 行业务代码和 1000 行编译器代码的复杂性是不同的。
一个代码审计人员一个小时大概可以审计 100 行到 1000 行代码不等,取决于该审计人员的经验能力和对代码的理解程度。对于个人而言,评估审计效率的最好方式就是坚持记录自己对不同组件的审计时间,这既能更好的了解自己的节奏,也能为后续代码审计计划提供时间参考。
影响代码审计速度的原因有很多,比如:
- 代码语言: 对于 C/C++ 这种内存不安全的语言需要更多关注底层细节;而 Java、Python 等内存安全的语言则更多关注上层逻辑实现;
- 代码风格: 代码风格整洁、注释清晰的项目通常比其他项目花费更少时间去审计;
值得一提的是,虽然代码量和审计时间有正相关的关系,但随着对某个项目的审计代码量增加,审计的效率也会提高,因为此时已经对项目有了更加深入的理解,审计十万行代码的时间通常也不会是五万行代码的两倍。因此在制定审计计划和时间投入时,需要仔细考虑上述相关因素。
知止而有得
在一个完美的代码安全审计项目中,每个模块的每一行代码都会被尽可能彻底的评估,但这个目标在现实世界中几乎是不可行的。时间和预算的限制会迫使你忽略一些代码模块,或者对每个模块的审计深度和覆盖率做出取舍。因此我们需要通过不断练习来提高自身敏感度,在屡次的失败或成功中吸取教训,作为下一次取舍和抉择的养料和经验。
在下节中将会介绍一些具体的代码安全审计的策略和技巧。在实践中也许某些方法比另外一些方法要高效,但经验告诉我们最好还是使用多种审计策略,并周期性的切换审计方法,这有多种原因:
- 你只能在有限的时间内保持精神高度集中;
- 多样性能帮助你保持纪律和激情;
- 不同的漏洞类型在其他视角下可能更容易被发现;
- 不同的人拥有不同的思考方式;
- ……
从全局来看,代码安全审计的方法是一个简单的三步循环流程:
- Plan: 审计规划,根据现有信息,确定这一阶段要使用的代码审计策略,以及审计的小目标,比如完成某个模块或者文件(目录)的审计、了解某个结构体的功能等;
- Work: 执行审计,根据前面制定的审计策略去执行,重点是做好这一过程中的审计记录;
- Reflect: 审计回顾,在完成该阶段审计后,反思一下自己在这一阶段是否有效利用时间,是否偏离了方向。然后根据前面审计学习到的经验去调整下一阶段的审计规划,比如重新划分结构、专注于安全相关的子模块等;
在实践中,每天大概会经历两到三次上述审计循环。在制定审计规划的时候,我们需要尽可能利用现有的信息,比如开发文档、接口文档等资料;在没有文档的情况下,就只能通过不断的审计循环去推断出代码设计结构。在审计初期也许会觉得狗啃泰山无从下口,一叶障目不见森林;但到了审计后期时走进代码就会像走进自己家一样,知道哪个模块的开发者刚刚学习了设计模式想要秀一秀技巧,知道哪些代码已经经过激烈的攻防对抗,做了许多防御性编程,也知道了哪些代码是上古屎山,为开发者避而远之。……
因此,往往我们在代码审计初期可以找到一些简单的安全问题,而在后期随着对应用的理解深入,可以发现更为复杂的逻辑漏洞,甚至设计上的缺陷。
万物皆有法
本节介绍一些具体的代码审计策略。如果说代码审计是一场战争,那么必然要讲究战略和战术。这也许只是前人的经验之谈,但确实是一种行之有效的方法论。
代码审计策略有多种,但是每每种策略都类似的属性,代表这种策略的特征。例如:
- 起点: 代码跟踪的起点;
- 终点: 该策略的目标,或者跟踪代码的终止点;
- 方法: 代码跟踪方法,跟踪数据流、控制流,跟踪方向是前向跟踪还是反向跟踪;
- 目标: 该审计策略所针对的是什么类型的漏洞;
- 难度: 表示该审计策略的执行难度,一般从 1 星到 5 星表示难度递增;
- 速度: 表示该审计策略的执行速度,也是从 1 星到 5 星表示从慢到快;
- 理解: 表示该审计策略所带来的代码理解。通常能带来更多理解的策略难度都会更大一些,但同时也能帮助研究人员发现更加复杂的漏洞;
还有对应优缺点等,下面是一些具体的审计策略。
策略一: 自顶向下
第一种代码审计策略就是通过直接分析代码去挖掘漏洞,这种方式通常要阅读代码并理解代码,需要更加集中注意力,但同时也带来对代码更加深入的理解。当然正面交锋也不是抡起膀子就干,可以进一步细分为以下执行策略。
数据分析
首先是数据流分析,通过跟踪用户的(恶意)输入数据去找到潜在的漏洞点。
key | val |
---|---|
起点 | 数据入口点,比如函数的参数、环境变量等 |
终点 | 最终的漏洞触发点,比如越权、注入、内存破坏等 |
方法 | 前向分析,数据流敏感、控制流敏感 |
目标 | 发现可被恶意输入触发的安全漏洞 |
难度 | ★★★★ |
速度 | ★ |
理解 | ★★★★ |
数据分析可能是大部分人所认为的代码审计方法,其核心是从恶意输入为起点,在代码中通过控制流进行前向审计,通常辅以有限的数据流分析。在审计过程中通过记录一系列传播的数据并进行跟踪,并配合安全边界分析和常用的漏洞类型去定位漏洞。
该方法是分析代码的有效方法,但是需要一些额外的经验用于判断应该跟进哪些模块和函数,否则路径会在短时间内分支爆炸。而且在一段时间的审计很容易走神并错失一些重要分支,然后和漏洞擦肩而过。
在面对类似于 Java 或者 C++ 等语言的项目时候,数据分析方法往往更加困难,因为通常跟踪原始输入会经过多个中间类,导致你打开了十几个文件之后还没有到达真正处理数据的代码。在这种情况下最好有设计文档的帮助,用以构建相对完备的威胁模型,否则最好还是先采用其他策略去理解系统。
模块分析
模块分析的一个隐含假设是代码模块以文件进行划分,因此实际上分析一个模块就是分析对应的源文件。
key | val |
---|---|
起点 | 文件开头 |
终点 | 文件末尾 |
方法 | 前向分析,数据流不敏感、控制流不敏感 |
目标 | 阅读模块中的每个函数,只记录潜在问题 |
难度 | ★★★★★ |
速度 | ★★ |
理解 | ★★★★★ |
模块分析的过程基本上就是从头到尾阅读一个源码文件,并且不跟踪其中调用到的外部函数,也不关心当前函数的引用情况,只将目前遇到的问题记录下来。也许你会觉得这个策略比较粗暴,但实际上许多资深的代码审计人员都很喜欢这种方法。比如前 NSA 的某安全顾问在审计新的代码仓库时都会先使用该策略,找到类似 util
的工具目录并逐行阅读其中的胶水代码,以对该项目的代码风格形成初步了解。
模块分析的优点是快速了解应用的的代码风格,对于高内聚的模块分析可以不走出文件,可以对模块内部实现有个初步理解。但缺点也很明显,这种分析方法比较费力,而且在长时间的持续分析后大脑很容易感到疲劳;另外在审计过程中记录所有的潜在问题也是个繁琐的工作,在若干小时的记录后很难有激情去继续坚持下去。因此,如果在执行此策略的过程中感到精神涣散的话,最好先休息一下,或者切换到其他不那么紧张的审计策略中。
引用分析
引用分析和模块分析类似,主要区别在于我们聚焦的是面向对象代码中的类或者结构体实现。
key | val |
---|---|
起点 | 某个对象实现 |
终点 | 所有对该对象的引用 (xref) |
方法 | 前向分析,数据流不敏感、控制流不敏感 |
目标 | 学习重要对象的接口和实现,并且寻找接口使用导致的错误 |
难度 | ★★★★ |
速度 | ★★ |
理解 | ★★★★★ |
对于面向对象的语言来说,该审计策略比单纯的模块分析要更为高效,因为往往对象本身是高内聚的。同时该分析过程也更不容易导致审计过程中出现方向偏离。但是和模块分析的特点一样,在审计过程中我们同样需要保持注意力集中,不然也可能会出现漏看的情况。
算法分析
在对应用的系统设计和数据结构有了足够的理解后,我们就可以选择一些安全相关的算法并分析其实现。
key | val |
---|---|
起点 | 算法的开始 |
终点 | 算法的结束 |
方法 | 前向分析,数据流不敏感、控制流不敏感 |
目标 | 分析算法实现,并找到潜在的设计和实现问题 |
难度 | ★★★★★ |
速度 | ★★ |
理解 | ★★★★★ |
这个审计策略的有效性依赖于我们所选算法的的相关性,因此需要在对审计目标有一定理解之后才能确定哪些是关键的算法和代码。这些关键算法通常涉及到应用安全模型的设计或者密码学实现,比如 Web 应用中的 sessionId 实现算法或者应用开发者自定义的加密校验算法等。
策略二: 自底向上
这一大类策略和上述的正面策略正好相反,主要从可能导致漏洞的底层代码着手。这类策略通常会使用一些自动化分析工具作为辅助,然后反向追踪去验证漏洞的触发路径。
敏感调用
指定一系列的敏感调用,反向分析这些调用是否构成可利用的漏洞。最简单的方式是通过正则表达式去指定敏感函数或者语句,通过文本搜索工具去查找潜在漏洞并进行验证。
key | val |
---|---|
起点 | 潜在的漏洞点 |
终点 | 任意用户可控的输入 |
方法 | 反向分析,数据流敏感、控制流敏感 |
目标 | 给定一系列潜在漏洞点,分析它们是否可以触发和利用 |
难度 | ★★ |
速度 | ★★★★ |
理解 | ★★ |
这个策略的优点是针对已知的漏洞类型可以达到较高的覆盖率,比如格式化字符串、命令注入等;而且该审计方法不是非常掉 san,可以有效节省和恢复注意力,由于漏洞列表的存在也不容易走偏,也可以稳步推进审计工作。
漏扫工具
除了手动指定敏感调用,我们还可以通过自动化静态分析工具去获取潜在漏洞点,比如 Sonar、Fortify 等都有较为完善的漏洞分类列表。
key | val |
---|---|
起点 | 潜在的漏洞点 |
终点 | 任意用户可控的输入 |
方法 | 反向分析,数据流敏感、控制流敏感 |
目标 | 给定一系列潜在漏洞点,分析它们是否可以触发和利用 |
难度 | ★ |
速度 | ★★★★ |
理解 | ★ |
该策略的分析和敏感调用分析过程类似,早期的静态分析工具只是简单的文本匹配,但当前已经有许多静态分析工具可以进行比较精确的数据流和上下文分析,有的需要依赖完整构建环境支持,比如 CodeQL,有的则可以在仅有源码的情况下进行分析,比如 weggli、semgrep 等。
这类静态分析工具的一大弊端是通常存在误报,如果在一千个扫描结果中只有几个是真正的漏洞,那么它们也往往很容易会被安全审计人员忽略。
接口分析
有时候漏洞并不单独产生在敏感的函数调用中,而是实现在某个类或者应用函数的代码中,比如一些命令执行中间函数或者 ORM 的数据库封装接口。
key | val |
---|---|
起点 | 应用对象接口或者函数的调用 |
终点 | 任意用户可控的输入 |
方法 | 反向分析,数据流敏感、控制流敏感 |
目标 | 给定一系列潜在漏洞点,分析它们是否可以触发和利用 |
难度 | ★ |
速度 | ★★★★ |
理解 | ★ |
有部分自动化分析工具可以编写自定义规则去进行扫描,但更多情况下只需要一个简单的 grep/findstr 命令就可以实现漏洞点查找和过滤。该方法需要对待审计代码有一定程度的理解才能知道哪些是潜在的安全敏感函数,而且由于我们只是不断在一个浅层的上下文中进行搜索跳转,所以这种审计策略只能帮助我们验证漏洞路径,对代码的理解几乎没有更多帮助。
除了针对源码的分析,对于二进制应用的逆向分析我们也可以运用类似的策略,在汇编代码中定位潜在的漏洞模式。比如对于 x86 的程序可以通过 MOVSX
指令去搜索潜在的整数溢出漏洞等,当然这是题外话了。
策略三: 见微知著
在我们经过前两类策略的审计后,已经对代码本身有了一定程度的理解,这时候就可以后退一步,纵观全局去审计应用整体的设计和实现。这类策略主要关注关注上层的设计缺陷逻辑问题,因此往往可以找到隐藏较深的严重漏洞。
系统建模
一般开发的过程是先完成顶层设计和排期,然后再分模块去进行具体编码实现。但对于安全审计而言这个过程可以反过来,即完成具体实现的分析后再反向推断整体的的设计思路,然后根据这个推测的思路去找到一些未曾触及的组件实现。
key | val |
---|---|
起点 | 待审计模块的起点 |
终点 | 安全漏洞 |
方法 | 随机应变 |
目标 | 通过行为建模还原模块的抽象行为,并寻找潜在的逻辑和功能漏洞 |
难度 | ★★★★ |
速度 | ★★ |
理解 | ★★★★★ |
如果想要对目标系统有更加深入的理解,那么该审计策略是一个极好的选择;但同时这也表示该方法的审计速度不会太快,因为基本上我们是从实现细节开始逆向还原出原始的设计架构。
值得一提的是,通常我们只需要对一些核心模块进行反向建模,比如应用的安全子系统、输入过滤模块或者其他广泛使用的核心组件等。在建模的过程中,我们实际上是把自己放在开发者或者架构师的位置去重新考虑模块的设计,因此这也是通过具体代码去挖掘逻辑漏洞的最佳策略。
在面对大型代码时,我们的大脑几乎不可能一次性把整个应用结构消化掉,因此就需要进行减枝。具体做法就是先对待审计代码做出一些猜测的假设,然后在实际测试中验证这些假设。不论假设是否正确,最终都能通过测试去加深自己对该系统的理解和认识。
安全边界
该审计策略的目标是从代码实现去还原开发者或者安全架构师预设的安全边界,从而对还原后安全边界进行进一步审计,构建实际攻击的威胁模型。
key | val |
---|---|
起点 | 所有安全相关的校验和检查代码 |
终点 | 安全漏洞 |
方法 | 随机应变 |
目标 | 通过已知的安全相关代码去推测还原目标的设计的安全边界 |
难度 | ★★★★ |
速度 | ★★★ |
理解 | ★★★★★ |
一个具体的方法是通过收集整理代码中的安全校验相关代码片段进行记录,然后对这些针对安全边界的检查进行分类整理,最后归纳出原始的安全层级划分。这些原始的安全验证是我们对应用安全边界建模的重要信息来源,该策略的优点是可以让我们专注于安全相关的代码区域,并且构建更为完整的设计架构。
设计验证
如果我们手中有目标应用的设计文档或者规范手册,那么一个直观的审计策略就是通过对比规范和实现代码来查找其中的未定义行为或者冲突之处。
key | val |
---|---|
起点 | 模块的起点 |
终点 | 模块的末尾 |
方法 | 前向分析,控制流敏感、数据流敏感 |
目标 | 挖掘代码实现中和设计有出入的漏洞点 |
难度 | ★★★ |
速度 | ★★★ |
理解 | ★★★ |
虽然大部分时候我们并没有那么详细的文档,但同样可以使用该策略来发现一些设计实现中的漏洞。通过重点关注代码实现中的灰色地带,或者说一些条件分支的临界处理,我们也可以大致推断出哪些是文档中所未定义的行为。简单来说,我们的目标是先推测和还原目标模块的主要功能和正常行为,然后再针对其中的临界情况进行重点审计。
审计技巧
前面已经介绍了代码安全审计常见的一些策略,主要用于为代码审计提供大致的战略方向。本节主要介绍一些实际代码审计工作中用到的小技巧,作为上述代码审计策略的补充。
阅读顺序
在审计某个模块代码时,我们可以通过跟踪数据流或者跟踪控制流的方式去阅读,那么那种方法比较好呢?答案是既不关心数据流也不关心控制流,而是只关注本模块的实现。因为多年的经验告诉我们,精神力是影响代码审计效率的重要原因,而在不同模块中来回跳转往往形成了精神的消耗。比如在某些复杂的项目代码中,查找某个函数的实现会不断地打开新文件,从而不断地涌出需要解决的新问题,在不断追踪的过程中,往往会迷失在好奇的海洋中,从而忘记了原来的审计任务。
如果确实要打破砂锅问到底,那么也建议在审计记录上先做个标记,等完成当前模块的审计后再对其进行深入分析。
故地重游
叔本华曾经说过,重要的书都应该连着读两遍。因为第二遍读的时候,你已经知道结局了,这样才能真正理解开头。另一个原因是第二遍阅读时,你有不一样的心情,可能会从另一种角度看待问题。
代码审计亦是如此,通常对于一段代码我们通常需要阅读多次才能找到其中所有类型的漏洞。比如在第一遍审计中,可能主要关心整数溢出、内存管理或者格式化字符串相关的安全漏洞;而第二遍的审计则主要关心功能性的实现,比如返回值检查和一些容易理解错误的 API 调用(如 strncpy、strlcpy);第三遍则主要关心线程间潜在的同步、竞争问题或者 TOCTOU 的资源访问管理,……诸如此类。
没有一个标准说对于某段代码应该审计阅读多少遍,具体情况需要根据代码具体的运行上下文进行判断,比如对于单线程代码就不需要关心线程同步问题了。但是无论如何,对于关键代码至少也要阅读两次,因为如果只看一次过的话很容易遗漏重要的代码路径。
审计笔记
在审计过程中是否要记录笔记其实因人而异,但是经验表明坚持记录大有裨益。
审计审计,审而不计则罔,计而不审则殆。结构化的笔记一方面可以帮助自己评估代码审计覆盖率,沉淀审计心得;另一方面也可以方便以后继续深入审计时可以快速回顾之前的工作。此外,不论作为打工人还是独立安全顾问,我们都需要对老板或甲方输出审计报告或者漏洞报告,这些资料就是重要的数据来源。
想法列表
我们可能在代码审计过程中会出现很多想法,比如在审计某个状态机时会想到可能引入一些异常的状态切换导致处理异常,或者某些用户可控的数据进入了其他模块的分支中可能导致对应模块校验出错等等。这些想法在审计过程中无法逐一去验证,否则就偏离了初始的审计规划。因此,我们还需要维护一个潜在漏洞列表,记录上述想法,表示系统中”可能“出现漏洞或者被攻击者利用的点。这个列表不需要非常详细,可能只是一个猜测或者灵感。将其记录下来之后可以等到后续有时间继续深入分析和验证,这样既能保证审计工作有序推进,也能保证自己的想法不被遗忘。
总结
代码安全审计的前期重点之一是评估自己的审计速度(知己)和代码量和复杂度(知彼),这样才能让自己的审计工作可量化、可管理,从而稳步推进审计进度;在代码审计过程中,使用三步循环流程不断增加审计覆盖率以及提高自己对代码结构的理解,在每一阶段的审计后,要及时记录所得所想,让工作落到实处。同时在此过程中也需要适时切换审计策略,合理使用和回复自己有限的注意力和意志力。
版权声明: 自由转载-非商用-非衍生-保持署名 (CC 4.0 BY-SA)
原文地址: https://evilpan.com/2022/01/22/code-audit/
微信订阅: 『有价值炮灰』
– TO BE CONTINUED.