CodeQL 学习笔记【4】CodeQL 与其他静态分析工具的对比

1,481 阅读11分钟

静态分析(静态代码分析或者叫静态程序分析)是一个过程,意思是在不运行代码的情况下分析应用程序代码的潜在错误或者漏洞

入口 和 出口 (sources and sinks)

我们用最常见的 SQL 注入漏洞来举例。

SQL 注入漏洞的主要原因是不受信任的、用户控制的输入被传入到程序的危险的函数中去了。为了在静态分析中表示这些,我们使用数据流、入口和出口等术语(data flow, sources, and sinks)来表达我们想表达的东西, 也就是说定义一些术语来方便研究人员之间的沟通、 人与工具之间的沟通、人与代码之间的沟通等等吧。

用户输入通常来自应用程序的入口点,即数据的来源。其中包括 HTTP 方法中的参数(如 GET 和 POST)或程序的命令行参数。这些被称为“入口” (sources)。

通过 source 传入的危险数据,可能传入到 MySQLCursor.execute() 或者 eval() 。这些危险的函数就被称之为 "出口"(sinks)。

当然了,不是说所有的 sinks 都一定会被利用。要使漏洞存在,必须是数据未经过滤的从 sources 一路传递到 sinks,在这种情况下,我们说数据从 sources 流到了 sinks ———— 存在一条 数据流(data flow)

graph LR
sources["sources\n\n不可信的输入"] --> sinks["sinks\n\n危险的函数"]

寻找 sources 和 sinks

如何找到 sources 和 sinks 呢?最简单的想法就是先找到所有的 sources 然后看数据是否能流动到某个 sinks,或者相反,找到所有的 sinks 然后看看是否能倒推到某个 sources 。

那么如何找到所有的 sources 呢,在以前没有 CodeQL 的时候,一般都是用正则匹配,比如 Django 中获取 HTTP 参数的方法是 username = request.GET.get("username")

那么我们大概可以使用这样的语句进行匹配 grep request, 但它也会匹配无害的函数名称和注释,例如: Def request_check(req)# This processes a request from the server 我们在分析中并不需要这些,因此会产生很多误报。

其次,这样的做法使得我们一次只能搜索一种 sources ,然后呢,我们通过搜索 GET 请求发现十几个 sources,追踪到 sinks 后发现都是无效的,然后 搜索 POST .....,最后可能发现数以千计的不受信任的数据来源。这太多了,太多的误报和太多的路径,而且需要追踪的路径大概率是指数级的。

然后为了解决这些问题,一些早期的代码审计工具被开发了出来。

早期静态分析工具

词法分析

因为正则匹配有太多的误报了,所以我们可以尝试借助编译器的力量来排除这些干扰。

编译器在编译的时候会把代码转化为一个一个的 "单词",并且我们可以获取到这些词的意义,这就可以去除掉很多对代码没啥关系的部分,比如注释。我们在这些单词的基础上进行分析就可以比较省力了。

通过查看一些最早的静态分析工具以及它们是如何演变的,可以更容易地理解 CodeQL 等现代静态分析工具的工作原理。

举一个词法分析的例子:

from django.db import connection

def show_user(request, username): 
    with connection.cursor() as cursor: 
        cursor.execute("SELECT * FROM users WHERE username = '%s'" % username)

然后使用 Python 的 tokenize 库将上述的第一行代码转换为 Tokens。

TokenInfo(type=63 (ENCODING), string='utf-8', start=(0, 0), end=(0, 0), line='') 
TokenInfo(type=62 (NL), string='\n', start=(1, 0), end=(1, 1), line='\n') 
TokenInfo(type=1 (NAME), string='from', start=(2, 0), end=(2, 4), line='from django.db import connection\n') 
TokenInfo(type=1 (NAME), string='django', start=(2, 5), end=(2, 11), line='from django.db import connection\n') 
TokenInfo(type=54 (OP), string='.', start=(2, 11), end=(2, 12), line='from django.db import connection\n') 
TokenInfo(type=1 (NAME), string='db', start=(2, 12), end=(2, 14), line='from django.db import connection\n') 
TokenInfo(type=1 (NAME), string='import', start=(2, 15), end=(2, 21), line='from django.db import connection\n') 
TokenInfo(type=1 (NAME), string='connection', start=(2, 22), end=(2, 32), line='from django.db import connection\n') 
TokenInfo(type=4 (NEWLINE), string='\n', start=(2, 32), end=(2, 33), line='from django.db import connection\n')

然后其他的代码也是同理,通过词法分析,我们可以得知代码中使用了哪些危险函数(而不是像以前一样用正则去匹配),并且排除了注释等干扰。

但是问题还是存在的,有很多的 sinks 被查找出来了,但是并没有什么用户可控的参数被传入,误报太多了!

数据流分析

随着技术的发展,静态分析工具采用了编译器的更多技术,例如语法解析和抽象语法树 (AST)。

比如可以通过 Python 的 ast 和 astpretty 模块,将上面的存在 SQL 注入的 Django 代码转换为 AST。

Module( body=[ 
    ImportFrom( lineno=2, col_offset=0, end_lineno=2, end_col_offset=32, module='django.db', 
        names=[alias(lineno=2, col_offset=22, end_lineno=2, end_col_offset=32, name='connection', asname=None)], 
        level=0, 
        ), 
    FunctionDef( lineno=5, col_offset=0, end_lineno=7, end_col_offset=78, name='show_user', 
        args=arguments( posonlyargs=[], 
            args=[ arg(lineno=5, col_offset=14, end_lineno=5, end_col_offset=21, arg='request', annotation=None, type_comment=None), arg(lineno=5, col_offset=23, end_lineno=5, end_col_offset=31, arg='username', annotation=None, type_comment=None), ], 
            vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[], 
        ) 
        # ...
    )]
)

我们可以使用 graphviz 库将 AST 可视化。

from django.db import connection

def show_user(request, username): 
   with connection.cursor() as cursor: 
       cursor.execute("SELECT * FROM users WHERE username = '%s'" % username)
graph TD
Module(Module) --> ImportForm[ImportForm\non line 1] & FunctionDef[FunctionDef\non line 3\nshow_user]
ImportForm --> alias[alias\non line 1]
FunctionDef --> arguments & With[With\non line 4]
arguments --> arg1[arg\non line 3\nrequest] & arg2[arg\non line 3\nusername]
With --> withitem & Expr[Expr\non line 5]
withitem --> Call[Call\non line 4] & Name[Name\non line 4\ncursor]
Call --> Attribute[Attribute\non line 4\nconnection.cursor] --> Name2[Name\non line 4\nconnection]
Expr --> Call2[Call\non line 5] --> Attribute2[Attribute\non line 5\ncursor.execute] & BinOp[BinOp\non line 5]
Attribute2 --> Name3[Name\non line 5\ncursor]
BinOp --> Constant[Constant\non line 5\nSELECT * FROM users WHERE username = '%s] & Name4[Name\non line 5\nusername]

现在,我们可以清楚地看到一个标有 “Call on line 5” 的节点,这是对方法的调用,其子节点表示其限定符和参数。

这样的话我们就可以通过 AST 来进行查询,例如查询出所有对 cursor.execute 的调用,然后去掉那些传入字面字符串变量的调用(剩下的就是传入动态变量的调用了呗)。

为了使我们的分析更加准确,我们可以使用源代码的另一种表示形式,称为控制流图 (CFG)。控制流图描述控制流,即在程序所有可能的运行语句中评估 AST 节点的顺序,其中每个节点对应于程序中的基元语句。这些原始语句包括赋值和条件。从节点发出的边表示该语句在程序的同一运行中的可能继承者。借助控制流图,我们可以跟踪代码在整个程序中的流动方式并执行进一步的分析。

为了可视化控制流图,我们将使用以下带有 SQL 注入漏洞的示例代码。请注意,代码已简化,因此更容易可视化和理解概念。

username = request.GET.get("username") 
if request.user.is_superuser: 
    sql = "SELECT * FROM users" 
    cursor.execute(sql) 
elif request.user.is_authenticated: 
    sql = f"SELECT * FROM users WHERE username={username}" 
    cursor.execute(sql) 
else: 
    print("404")

根据上述源代码创建的控制流图如下所示:

graph TD
Start --> 1["1:username = request.GET.get('username')"] --> 2["2:if request.user.is_superuser:"]
2 --True--> 3["3:sql = 'SELECT * FROM users'"] --> 4["4:cursor.execute(sql)"] --> Stop
2 --False--> 5["5:if request.user.is_authenticated:"] --True--> 6["6:sql = f'SELECT * FROM users \nWHERE username={username}'"] --> 7["7:cursor.execute(sql)"] --> Stop
5 --False--> 9["9:print(404)"] --> Stop

通过 AST 我们将能够获取第 4 行和第 7 行上的两个方法调用,但我们还无法判断数据是否从 sources 流向 sinks 。但是通过控制流程图可以帮助我们解决这个问题。

污点跟踪

虽然我们可以利用控制流图来检查 sources 和 sinks 之间是否存在连接。但数据流分析的问题在于,它只跟踪“保值数据”,即不会更改的数据。

例如,如果一个字符串与另一个字符串连接,则会创建一个具有不同值的新字符串,那么数据流分析不会跟踪它。在我们的示例代码和上面的控制流图中就是这种情况。

为了解决这个问题,我们可以使用污点跟踪,它的工作原理类似于数据流分析,但规则略有不同。污点跟踪将某些输入(sources)标记为“污点(tainted)”(此处表示不安全,用户控制),这允许静态分析工具检查污点是否一直传播到我们应用程序中的 sinks 点,例如危险函数的参数。

就像在我们的示例中一样,通过污点跟踪,我们可以检测到数据从第 1 行的 username 参数流经到第 5 行的 if 条件 if request.user.is_authenticated: ,再流向第 7 行的 cursor.execute(sql) 接收器。

graph LR
Start(Sources\n\n不可信的输入) --"data flow path \n数据流路径"--> Stop(Sinks\n\n危险函数调用)

总而言之,污点跟踪分析(与数据流分析相比)允许跟踪数据,即使其值被更改,例如,污点字符串与另一个字符串连接,或者它被赋值为对象的属性等情况下,依旧可以继续跟踪。

让我们用另一个非常相似的例子来可视化数据流路径。

from django.db import connection 

def profile(request): 
    with connection.cursor() as cursor: 
        username = request.GET.get("username") 
        sql = f"SELECT * FROM users WHERE username={username}" 
        cursor.execute(sql)
graph TD
Start("HTTP request to an endpoint\n\n def profile(request):") --> S2("HTTP parameter\n\nusername = request.GET.get(#quot;username#quot;)") --> S3("String conncatenation of the parameter in a SQL statement\n\nsql = f#quot;SELECT * FROM users WHERE username={username}#quot;") --> S4("Execution of the SQL statement\n\ncursor.execute(sql)")

通过污点跟踪,我们就可以跟踪 sources 从 username 一直到 sql 中。

通过污点追踪分析,我们成功解决了第二个问题——过多的误报结果。现在扫描器只报告那些不信任的数据流完全流入危险函数的问题。这展示了静态分析的强大能力 ———— 从手动分析上百个 sources --是否连接上?--> sinks 变为了 sources --是否可利用?--> sinks (因为已经自动排除了那些连接不上的了)

如果一个程序使用了过滤函数,比如使用MySQLdb.escape_string()来防止SQL注入,那么只有在不信任的数据没有经过验证、转义或其他方式的清理时,漏洞才应该被报告为真正的漏洞。

许多静态分析工具都内置了这种支持,不会报告那些已经被过滤过的漏洞,例如使用MySQLdb.escape_string()进行过滤过的漏洞。如果数据流路径中的某个节点使用了过滤方法,那么工具将停止该数据流的传播。

然而,可能会出现代码库使用自定义的、不常见的过滤方法,而这些方法静态分析器并不支持,这种情况下,工具仍然会报告这个漏洞。但是,如果你熟悉代码库应该可以很容易地验证并排除这些结果。静态分析工具通常提供了一种方法来定义什么函数构成了过滤器或污点步骤(两个数据流节点之间的边缘),以允许用户根据自己的代码和库自定义分析。

AST 和 CFG 是静态分析工具运行的一些最流行的源代码表示形式,但也有其他表示形式。对于安全研究人员来说,这些可能不那么相关,但您可能会在文档或结果中遇到它们。

其他的分析方法

调用图是函数或方法之间潜在控制流的表示形式。调用图中的节点表示函数,有向边表示一个函数调用另一个函数的潜力。调用图表示函数之间的关系,并允许我们查看哪个函数可以调用不同的函数,或者函数是否可以调用自身。

另一种流行的形式是单一静态分配 (SSA),其中修改了控制流图,以便每个变量只分配一次。SSA表格大大提高了各种数据流分析方法的精度。

CodeQL

CodeQL 提供对漏洞的自动扫描,也可以用作探索代码库和协助手动测试的工具。CodeQL 有许多用途,例如:

  • 自动扫描数百种漏洞类型的源代码。在此处查看支持的 CWE 列表。
  • 变异分析。如果在我的代码库中发现了漏洞,例如 SQL 注入,我可以使用 CodeQL 查看代码库的不同部分是否存在其他相同漏洞的情况。
  • 在手动代码审查期间提供帮助。我们可以对分析的代码库“询问 CodeQL 问题”,例如:
    • 我的攻击面是什么?我应该从哪里开始审核?
    • 我的代码库中的 sources(不安全的用户提供的输入)是什么?
    • 什么是 sinks(危险功能)?
    • 这些来源是否最终具有任何危险或不受信任的功能?

CodeQL 是由 Semmle(于 2019 年被 GitHub 收购)开发的一款功能强大的静态代码分析工具,基于牛津大学团队十多年的研究。CodeQL 使用数据流分析和污点分析来查找代码错误、检查代码质量并识别漏洞。目前,支持的语言包括 C/C++、C#、Go、Java、Kotlin、JavaScript、Python、Ruby、TypeScript 和 Swift。

CodeQL 背后的关键思想是,它通过创建一个关于程序的事实数据库,然后使用一种称为 QL 的特殊查询语言来查询数据库中是否存在易受攻击的模式,从而将代码作为数据进行分析。

一旦我们有了 CodeQL 数据库,我们就可以问它一些关于我们想要在源代码中找到的模式的问题(查询)。为了查询 CodeQL 数据库,使用 QL 查询语言。QL 是一种富有表现力的、声明性的、逻辑的查询语言,用于识别数据库中的模式,即漏洞,例如 SQL 注入。CodeQL 查询是开源的,任何人都可以创建和贡献 CodeQL。

flowchart LR
  subgraph S1["CodeQL 数据库"]
    direction LR
    subgraph S2["引擎"]
     direction LR
     Sources --数据流路径--> Sinks
    end
  end
C["源代码"] --> S1
A["CodeQL 查询"] --> S1 --> B["结果\n源代码中的漏洞"]