前言
在编译拾遗(一):代码静态行为分析中,我们介绍了基本的静态代码行为分析的思路。在文章中抛出了很多很有难度的技术话题,得到了很多用户比较正面的反馈。在和用户读者交流的过程中,“污点追踪”这个词被反复提及,包括我们团队内部的技术同学在 PoC 的时候,也编写了一个“Demo”去实现“污点追踪”的效果。
YAK
污点追踪:关注的是指定的“污点”数据从输入源流动到程序中可能危险的操作中的路径。在安全领域,污点追踪主要用于潜在的安全漏洞,如数据泄露或者注入攻击。
注意:污点分析是只有“安全领域”的概念,实际在编译领域并没有一个叫污点分析的概念。与之对应的过程应该叫“数据流分析”或者“变量支配分析”。
YAK
甚至特别有意思的是,做安全的同学大多数并不了解“污点分析”背后的基本计算机科学逻辑,很多安全领域的论文都把它当成研究课题,这其实是不合适的。
基本概念
我们使用一段伪代码来描述污点追踪的问题和挑战:
s = file.ReadFile("test.txt")~
w = i => {
os.System(f"bash ${i}")
}
if e {
w(s)
}
这段代码非常简单,那么我们总结一下这个过程:
要对这一段代码进行分析,需要思考如下几个问题:
1.上面的代码是伪代码,那么不完整代码如何分析行为?
2.函数过程间分析,我们应该怎么实现函数跳转?过程间分析带来的挑战有哪些?
3.IF 如何处理?每个分支都要步入吗?
4.输入从 file.ReadFile 中读取,那么我们应该从 ReadFile 开始分析吗?
在本篇文章中,我们尝试会对上面几个问题都有一个明确的解答,大家可以认真阅读,最后思考一下看看和自己想象的静态行为分析到底有没有差别。
从污点分析的角度看,这里存在两个重要概念:源(Source)和汇(Sink)。源是指数据可进入的地方,这里,源是 "ReadFile" 函数,用于读取外部用户可能控制的数据。汇,是指数据可以影响的地方,在这段代码中是 "System" 函数,它执行 bash
命令。
我们可以看到源(Source)是 ReadFile("test.txt")
,这个读取的文件内容被赋值给了 s
,然后 s
被传递给函数 w
,最后作为命令参数在 System
这个汇(Sink)里执行。所以从源到汇形成了一个可能的污点传播路径。
污点追踪的“最大缺陷”
当我们明白了基本概念之后,本节内容将指出“污点追踪”这种思考逻辑的一个缺陷:思维方向固定是从 ReadFile()
,但是我们有时候不能去观察所有的文件 IO 部分。如果读取了一个文件,并在内存中处理的链路非常长,那么分析过程将会无比痛苦。
很多时候,作为一个“人”,我们的思维实际上并不是“污点追踪”,而是“逆向污点追踪”,人去搜索源码,搜索到所有执行命令的地方,然后观察执行命令的地方在哪儿被使用了,一层一层向上追踪,观察输入的部分能不能控制执行命令。我们惊奇的发现,大多数人居然更愿意接受“逆向污点追踪”思考方式,毕竟面对动辄十几万行的源码,谁能从头说得清参数消亡在哪里了呢?
从安全代码审计引擎来说,“逆向污点追踪”一直是一个大家不愿意聊的话题,因为相对于正向思考的逻辑,逆向追踪的技术对 AST 分析太不友好了。
如果代码审计系统的研发水平卡在 AST 的层面的话,注定了“逆向污点追踪”一定是一个非常痛苦的过程。但是既然有这篇文章,我们肯定还是会提出相应的对策和正确的解答。
编译视角下的正逆向污点追踪
现在,我们忘掉我们是一个安全工程师的大背景,污点追踪这个话题,本质上是数据流追踪。熟悉编译拾遗(一):代码静态行为分析内容的同学,可以很容易理解到“UD/DU链”这个层面,我们可以使用UD和DU链分析技术去追踪数据流。
Use-Def链分析一般描述的是,从使用到定义的分析技术,这是一种“支配方向的向上分析”的技术。
Def-Use链分析一般描述的是,从定义到使用到分析技术,一个变量在哪儿产生,最后消亡在哪里了,对应的就是“向下分析技术”。
我们解释到这里,我想读者已经明白为什么我们在前篇花了大量篇幅去解释基于Use-Def链和Def-Use链的静态分析技术和基本方法了。
向上分析经典案例
我们可以构造一个非常经典的案例,来展示“向上分析”和“过程间分析”的惊人效果。针对如下代码,分析 f
的值应该取决于谁?或者说,f被谁支配?
a = 1
b = (c, d, e) => {
a = c + d
return d, c
}
f = b(2,3,4); dump(f)
这段代码非常容易理解,我们通过人脑简单观察发现,f
的值应该是[3,2]
。代码段中出现了1,2,3,4
四个值,我们的分析目标是应该是,f,那么,f 和 1,4
是无关的,我们在程序分析结果中不应该包含1
或4
。
我们使用 Yaklang SSA API 进行分析,通过UD关系,得到一个图:
strict digraph {
rankdir = "BT";
n0 [label="t9: f=main$1(t6,t7,t8)"]
n1 [label="main$1"]
n2 [label="t7: 3"]
n3 [label="t6: 2"]
n5 [label="c"]
n6 [label="d"]
n0 -> n3 [label=""]
n5 -> n3 [label=""]
n1 -> n3 [label=""]
n1 -> n5 [label=""]
n0 -> n1 [label=""]
n1 -> n6 [label=""]
n0 -> n2 [label=""]
n6 -> n2 [label=""]
n1 -> n2 [label=""]
}
上图渲染之后为:
YAK
注意:这个图是程序生成的,并不是手写节点绘制的,展示支配关系大多数遵循 SSA 格式:全局唯一符号跨越过程,可以通过 b: main$1 进入。这个支配关系核心表示,f=main$1(t6,t7,t8) 中 f 的核心支配链,也可以认为是 SSA 中各项 Use-Def 链的整合,而不是 call main$1 指令的核心支配。
我们发现,如果要得出正确的结论,不去进行“过程间分析”是不可能的。如果我们不进入b
函数,那么我们就会认为,2,3,4
都是b
的参数,都会支配f
。这显然是不可以接受的。那么我们如何解决这个问题?
YAK
过程间分析,顾名思义,就是跨越单个过程(或函数,方法等)的边界进行分析的技术。它是编译器优化和程序理解的重要工具,可以帮助识别程序中跨越函数或过程边界的数据流和控制流。
相对于只在单个过程内进行分析的技术,如数据流分析或控制流分析,过程间分析可以提供更全局的视角,从而可能带来更深度的优化和更精确的程序行为理解。然而,过程间分析的难度也相对更大,需要处理更多的复杂性,例如函数指针,递归调用,动态分派等问题。
我们需要跨越b
函数内部,并且还是从“返回值进入”,并且从“形式参数”穿越出来,才能确定结果到底是2,3
还是2,3,4
。那么我们具体的分析过程是什么,因为大家对 SSA IR 的熟悉程度有限,我们以 AST 为视角介绍这个过程:
如果你的目标是 AST 的话,首先,你需要知道 b 对应的 AST 的结构是什么,找到他的 RETURN 语句,RETURN 对应的变量分别为 b, c
,我们分别分析 b
和c
的用法,发现,a = c + b
和 f
几乎没有啥关联,跳过,c,d
最终是通过形参传入的,那么就应该去找 b
对应的 (2,3,4)
中 c,d
的位置为2,3
了,分析到常量了(Terminal Node)意味着已经没有再向寻找支配的必要了。
一般来说跨过程的 AST 需要能识别函数在 AST 中是在哪里定义的,AST 中的函数本身也十分复杂,比如说 lambda / anonymous 函数,标准函数,闭包函数,甚至每一个语言的 AST 对函数的定义都不一样,如果基于 AST 去分析过程间数据流,就需要多语言,多过程均支持。
听到这个过程,可能你已经不是特别想去操作 AST 了,摸清楚一个语言的 AST 的分析过程都十分痛苦,更不用说实现一个通用编译器过程,并在过程中追踪数据流了。
YAK
注:分析AST不是说没有办法追踪支配关系,而是AST注重高级封装和过程,有多少种类型的AST节点,就需要针对多少种节点进行分析策略,而且需要AST本身做好“正向”和“逆向”关联。这些额外工作,注定了AST不具备普适性和工程价值,这也是大多数SAST方案的死亡之路。
难题对策:过程间分析
过程间分析经过我们最近的探索,实际上它并不适合 AST 视角去做,具体的原因我们在上节末尾有提到。实际我们更适合分析“指令集”的“跨过程”。
IR 如果不熟悉的话,我们可以以汇编举例子:函数参数压栈跳转实际上对应需要进行两个分析操作:
压栈的指令需要记下来,因为他们会弹出之后作为参数使用。
最后计算完成,执行完指令之后,返回值再压栈,跳回原位置,处理栈中返回值。
YAK
如果汇编这个例子和AST都没法理解过程间分析指的是什么,那可能说明你现在还不具备探讨“过程间分析”的基础知识,需要去补充一下这方面的基础知识。
最重要的是,“指令”级别的过程间分析基本只有一种形式,他的形参传递方式非常单一;同样的“返回值”的传递方式也十分单一。
我们使用“类汇编”的指令函数执行过程描述过程间分析,方便用户可以直观理解“指令函数间”和“AST函数间”分析的两个区别。当然我们知道这两个有区别,但是不一定必须使用汇编级过程间分析技术,因为这显然也并不是一个好分析方向,因为寄存器对数据流分析的干扰实在有点大。
如果我们可以有一种产物,可以同时兼具 AST 的“易理解”的优势,又同时具备“指令”的线性逻辑和过程间形式简单,那就可以提出通用解决方案来解决“过程间分析”的老大难题。当然,这个产物就是 SSA IR,他可以既保持中间产物的单一流向(不必受重复值干扰),同时也能把上层各式各样的 AST 抽象成同一种过程间转换逻辑。
重新审视过程间分析案例
a = 1
b = (c, d, e) => {
a = c + d
return d, c
}
f = b(2,3,4)
经过我们上面的提示,对 IR 进行过程间分析实际上是正途,那么 IR 具体长什么样子呢?
main
type: ( ) -> null
entry-0: (true)
<any> t10 = undefined-dump
<[]any> t9: f = call <(any,any,any ) -> []any> main$1<b> (<number> 2, <number> 3, <number> 4) []
......
......
<any> t11: _ = call <any> t10: dump (<[]any> t9(f)) []
extern type:
extern Value:
main$1 <any> c, <any> d, <any> e
parent: main
sideEffects: a
type: (any,any,any ) -> []any
entry-0: (true)
<any> t4 = <any> c add <any> d
ret <any> d, <any> c
extern type:
extern Value:
Values: 1
0: Call: main$1(2,3,4)
在上述 Yaklang SSA HIR 指令集中,我们删除了一些干扰项,可以做如下解释,main$1
指的是b函数,真正主程序入口只有三个相关指令:
1.声明一个 undefined dump <any>: t10 = undefined-dump
2.函数调用:f = call(2,3,4)
编译为:t9(f) = call main$1(b) (2,3,4)
3.函数调用:dump
实际上,我们只从第二个指令分析,进入main$1
后直接跟随d,c
即可找到参数。我们只分析这个指令,完全不关心这个顶层语言是谁,因为在前置的编译过程中,我们已经实现了 AST 到 HIR 的编译。
并且我们这么去做过程间分析,只需要处理一种过程跳转,并且指令也相对不受寄存器干扰,非常简单易懂并且振奋人心。
过程间分析的工程化技巧
上述的过程实际不太依靠“人脑”,是完全可以编程实现这个分析过程的,因此我们可以编写一个可以进入 CI 的测试案例,在过程间分析技术迭代过程中,这个测试案例能运行通过,即可以认为我们这个过程间分析的基本技术是具备的,并且能得到一个比较好的效果:
func TestFunctionTrace_FormalParametersCheck_2(t *testing.T) {
prog, err := Parse(`
a = 1
b = (c, d, e) => {
a = c + d
return d, c
}
f = b(2,3,4);
dump(f)
`)
if err != nil {
t.Fatal(err)
}
prog.Show()
check2 := false
check3 := false
noCheck4 := true
prog.Ref("f").Show().ForEach(func(value *Value) {
value.GetTopDefs().ForEach(func(value *Value) {
d := value.Dot()
_ = d
value.ShowDot()
if value.IsConstInst() {
if value.GetConstValue() == 2 {
check2 = true
}
if value.GetConstValue() == 3 {
check3 = true
}
if value.GetConstValue() == 4 {
noCheck4 = false
}
}
})
})
if !noCheck4 {
t.Fatal("literal 4 should not be traced")
}
if !check2 {
t.Fatal("the literal 2 trace failed")
}
if !check3 {
t.Fatal("the literal 3 trace failed")
}
}
这个案例中,我们会对 f
进行顶级定义的追踪,如果追踪过程中,发现缺少 2,3
字面量,说明基本分析流程失效,如果发现分析结果包含4
说明过程间分析失效。
我们可以用同样的技术,构建很多的代码段(代码案例):MVP,然后这些 MVP 必须明确审计出正确的结果,以证明我们的分析技术实际上都生效了,并且可以追踪到特殊的情况。
当然,因为篇幅问题,我们省略掉了一些具体代码如何保持上下文传递的技术方案,你可以随时查看我们的开源代码获得这方面的信息。
结语
文章描述到这里,我想你对污点追踪应该有了非常清醒的认知,原本各种模糊的含糊其辞,充满公式的污点追踪过程应该可以变成了“代码”的过程。并且实际上,你应该抛弃掉“污点追踪”带给你的误导,直接看到污点追踪技术的分析本质。
END
更新日志
Yaklang 1.3.0-sp3
1. 修复 Windows 自定义安装路径中 HOME 不同步的问题
2. 重构 http 包底层,统一使用与 poc 相同的 HTTP 库功能
3. 分离 SSA 中自由变量和捕获变量
4. 修复 HTTP Fuzzer 中数据包变换的 BUG
5. 新增一个提取 Favicon URL 的小接口 @TimWhiteZ
6. Synscan 新增自定义网卡的接口
7. 静态分析新增提示“弃用”
8. 优化 CLI 与参数的展示/解析的功能
9. Vulinbox 更新逻辑漏洞测试靶场
10. 新增 For-Range Zero 的特性
11. 优化 tls.Inspect 接口
12. 新增 sandbox 包,可以在yak脚本中创建安全执行代码的沙箱
13. 新增新的批量扫描接口
14. 静态分析新增 include / eval 的支持
15. web fuzzer matcher 和 extractor 新增 ID 以保证生成的数据的匹配顺序
Yakit 1.2.9-sp1
1. 插件批量执行上线全新UI
2. 修复Historytag筛选抖动问题,增加tag搜索
3. 将菜单栏与设置功能优化为点击出现,避免影响操作
4. 修复菜单栏展开收起缓存问题
5. MITM增加过滤Websocket功能
6. 编写代码页增加弃用标识和提示
YAK官方资源
Yak 语言官方教程:
https://yaklang.com/docs/intro/
Yakit 视频教程:
https://space.bilibili.com/437503777
Github下载地址:
https://github.com/yaklang/yakit
Yakit官网下载地址:
https://yaklang.com/
Yakit安装文档:
https://yaklang.com/products/download_and_install
Yakit使用文档:
https://yaklang.com/products/intro/
常见问题速查:
https://yaklang.com/products/FAQ