freeBuf
主站

分类

云安全 AI安全 开发安全 终端安全 数据安全 Web安全 基础安全 企业安全 关基安全 移动安全 系统安全 其他安全

特色

热点 工具 漏洞 人物志 活动 安全招聘 攻防演练 政策法规

点我创作

试试在FreeBuf发布您的第一篇文章 让安全圈留下您的足迹
我知道了

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

编译拾遗(二):揭秘污点追踪的困扰与对策
yaklang 2024-01-30 14:37:47 125989

@V1ll4n

在《编译拾遗(一)》中,我们介绍了基本的静态代码行为分析的思路。在文章中抛出了很多很有难度的技术话题,得到了很多用户比较正面的反馈。在和用户读者交流的过程中,“污点追踪”这个词被反复提及,包括我们团队内部的技术同学在 PoC 的时候,也编写了一个“Demo”去实现“污点追踪”的效果。

污点追踪:关注的是指定的“污点”数据从输入源流动到程序中可能危险的操作中的路径。在安全领域,污点追踪主要用于潜在的安全漏洞,如数据泄露或者注入攻击。

注意:污点分析是只有“安全领域”的概念,实际在编译领域并没有一个叫污点分析的概念。与之对应的过程应该叫“数据流分析”或者“变量支配分析”。

甚至特别有意思的是,做安全的同学大多数并不了解“污点分析”背后的基本计算机科学逻辑,很多安全领域的论文都把它当成研究课题,这其实是不合适的。

基本概念

我们使用一段伪代码来描述污点追踪的问题和挑战:

s = file.ReadFile("test.txt")~
w = i => {
    os.System(f"bash ${i}")
}
if e {
    w(s)
}

这段代码非常简单,那么我们总结一下这个过程:

1706596476_65b8987c28dc17c4ba8a2.png!small

要对这一段代码进行分析,需要思考如下几个问题:

  1. 上面的代码是伪代码,那么不完整代码如何分析行为?
  2. 函数过程间分析,我们应该怎么实现函数跳转?过程间分析带来的挑战有哪些?
  3. IF 如何处理?每个分支都要步入吗?
  4. 输入从 ReadFile中读取,那么我们应该从 ReadFile 开始分析吗?

在本篇文章中,我们尝试会对上面几个问题都有一个明确的解答,大家可以认真阅读,最后思考一下看看和自己想象的静态行为分析到底有没有差别。

从污点分析的角度看,这里存在两个重要概念:源(Source)和汇(Sink)。源是指数据可进入的地方,这里,源是 "ReadFile" 函数,用于读取外部用户可能控制的数据。汇,是指数据可以影响的地方,在这段代码中是 "System" 函数,它执行 bash 命令。

我们可以看到源(Source)是 ReadFile("test.txt"),这个读取的文件内容被赋值给了 s,然后 s 被传递给函数 w,最后作为命令参数在 System 这个汇(Sink)里执行。所以从源到汇形成了一个可能的污点传播路径。

污点追踪的“最大缺陷”

当我们明白了基本概念之后,本节内容将指出“污点追踪”这种思考逻辑的一个缺陷:思维方向固定是从 ReadFile(),但是我们有时候不能去观察所有的文件 IO 部分。如果读取了一个文件,并在内存中处理的链路非常长,那么分析过程将会无比痛苦。

很多时候,作为一个“人”,我们的思维实际上并不是“污点追踪”,而是“逆向污点追踪”,人去搜索源码,搜索到所有执行命令的地方,然后观察执行命令的地方在哪儿被使用了,一层一层向上追踪,观察输入的部分能不能控制执行命令。我们惊奇的发现,大多数人居然更愿意接受“逆向污点追踪”思考方式,毕竟面对动辄十几万行的源码,谁能从头说得清参数消亡在哪里了呢?

1706596486_65b8988699af27d330cdd.png!small

从安全代码审计引擎来说,“逆向污点追踪”一直是一个大家不愿意聊的话题,因为相对于正向思考的逻辑,逆向追踪的技术对 AST 分析太不友好了。

如果代码审计系统的研发水平卡在 AST 的层面的话,注定了“逆向污点追踪”一定是一个非常痛苦的过程。但是既然有这篇文章,我们肯定还是会提出相应的对策和正确的解答。

编译视角下的正逆向污点追踪

现在,我们忘掉我们是一个安全工程师的大背景,污点追踪这个话题,本质上是数据流追踪。熟悉《编译拾遗(一)》的内容的同学,可以很容易理解到“UD/DU链”这个层面,我们可以使用UD和DU链分析技术去追踪数据流。

  1. Use-Def链分析一般描述的是,从使用到定义的分析技术,这是一种“支配方向的向上分析”的技术。
  2. 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=""]
}

上图渲染之后为:

1706596524_65b898acf14b44b863931.png!small


注意:这个图是程序生成的,并不是手写节点绘制的,展示支配关系大多数遵循 SSA 格式:全局唯一符号跨越过程,可以通过 b: main$1 进入。这个支配关系核心表示,f=main$1(t6,t7,t8) 中 f 的核心支配链,也可以认为是 SSA 中各项 Use-Def 链的整合,而不是 call main$1 指令的核心支配。

我们发现,如果要得出正确的结论,不去进行“过程间分析”是不可能的。如果我们不进入b函数,那么我们就会认为,2,3,4都是b的参数,都会支配f。这显然是不可以接受的。那么我们如何解决这个问题?

过程间分析,顾名思义,就是跨越单个过程(或函数,方法等)的边界进行分析的技术。它是编译器优化和程序理解的重要工具,可以帮助识别程序中跨越函数或过程边界的数据流和控制流。

相对于只在单个过程内进行分析的技术,如数据流分析或控制流分析,过程间分析可以提供更全局的视角,从而可能带来更深度的优化和更精确的程序行为理解。然而,过程间分析的难度也相对更大,需要处理更多的复杂性,例如函数指针,递归调用,动态分派等问题。

我们需要跨越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 的分析过程都十分痛苦,更不用说实现一个通用编译器过程,并在过程中追踪数据流了。

注:分析AST不是说没有办法追踪支配关系,而是AST注重高级封装和过程,有多少种类型的AST节点,就需要针对多少种节点进行分析策略,而且需要AST本身做好“正向”和“逆向”关联。这些额外工作,注定了AST不具备普适性和工程价值,这也是大多数SAST方案的死亡之路。

难题对策:过程间分析

过程间分析经过我们最近的探索,实际上它并不适合 AST 视角去做,具体的原因我们在上节末尾有提到。实际我们更适合分析“指令集”的“跨过程”。

IR 如果不熟悉的话,我们可以以汇编举例子:函数参数压栈跳转实际上对应需要进行两个分析操作:

  1. 压栈的指令需要记下来,因为他们会弹出之后作为参数使用。
  2. 最后计算完成,执行完指令之后,返回值再压栈,跳回原位置,处理栈中返回值。

如果汇编这个例子和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 必须明确审计出正确的结果,以证明我们的分析技术实际上都生效了,并且可以追踪到特殊的情况。

当然,因为篇幅问题,我们省略掉了一些具体代码如何保持上下文传递的技术方案,你可以随时查看我们的开源代码获得这方面的信息。

结语

文章描述到这里,我想你对污点追踪应该有了非常清醒的认知,原本各种模糊的含糊其辞,充满公式的污点追踪过程应该可以变成了“代码”的过程。并且实际上,你应该抛弃掉“污点追踪”带给你的误导,直接看到污点追踪技术的分析本质。

# 黑客 # 网络安全 # web安全 # 网络安全技术 # 编译
免责声明
1.一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。
本文为 yaklang 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
yaklang LV.8
做难而正确的事!
  • 153 文章数
  • 106 关注者
独立SyntaxFlow功能?IRify,启动!
2025-03-31
那我问你,MCP是什么?回答我!
2025-03-24
SyntaxFlow实战CVE漏洞?那很好了
2025-03-14
文章目录