Joern是源码级的静态分析工具,支持多种语言(二进制有ghidra支持),接下来将从Joern的基本概念,Joern的基本使用,到使用Joern来进行PHP漏洞复现,在到实际使用过程中遇到的问题和解决方案(降低误报率) 来进行介绍。

一、Joern的基本概念

在使用Joern对代码进行分析时,其主要步骤是将项目代码转换为代码属性图(CPG),再提供查询接口来供用户基于CPG进行漏洞挖掘。

Robust parsing ; Code Property Graphs ; Taint Analysis ; Search Queries ; Extendable via CPG passes

Joern的官方文档中介绍其核心特点有:

  1. 强大的解析能力,指的是Joern内含多种解析器,支持将多种语言转换为代码属性图(CPG)
  2. Joern中的代码属性图是其进行静态分析的根基,该数据结构包含程序语法、控制流和数据流的相关信息

  3. 污点分析,Joern提供污点分析引擎,用户可以通过定制化的方式来对攻击者可控数据进行分析此外灵活搜索查询和CPG可扩展也是Joern的优点。对于图CPG节点和CPG图本身,Joern支持用户对其灵活的访问和修改

上图展示了用户使用Joern来进行漏洞挖掘的完整逻辑:

  1. 首先项目导入,Joern会根据用户提供的项目路径和路径下的文件名后缀来对项目进行解析生成代码属性图,比如项目中如果主要是cpp文件,Joern就会将项目识别为c++项目
  2. 生成的代码属性图将加载到Joern的shell中,shell会提供访问代码属性图的接口
  3. 用户依据自己积累的漏洞模式,通过查询的方式,对目标项目进行漏洞挖掘。比如通过设置source点和sink点,来进行污点传播分析(在第二节中介绍)
  4. Joern输出具有漏洞模式的代码传播路径,快速的帮助用户定位可能存在漏洞的地方,此时需要用户通过审计以及编写poc的方式确认漏洞是否存在

二、Joern的基本使用

使用静态分析工具常见的要求是分析程序从source点到sink点的传播路径,也就是污点传播分析。

这里的source点从宏观上讲,指的是程序中攻击者的可控输入点,具体来说如在php中的$_GET $_POST $_SERVER, 设备固件中HttpGetEnv中的返回值,main函数中的argv等。

sink点指的是程序中的污点,当攻击者输入可以进入到污点将发生安全风险,如危险函数system popen mysql_queryi的输入参数,不局限于危险函数,有特征的字符串拼接也可以设置为sink点,如"SELECT UPDATE INSERT",当程序中有sql query语句拼接特征的代表有注入风险可能。

接着将从Joern官网的例子来演示用Joern进行污点传播分析的基本使用

https://docs.Joern.io/cpgql/data-flow-steps/

//目录 :c/X42.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {
  char buffer[10];
  if (argc > 1 && strcpy(buffer, argv[1]) == 0) {
    fprintf(stderr, "It depends!\n");
    exit(42);
  }
  printf("What is the meaning of life?\n");
  exit(0);
}
‍‍```

在c目录下的文件有如上的代码:

Joern> importCode("./c")
Joern> def source = cpg.method.name("main").parameter

Joern> def sink = cpg.call.name("strcpy").argument

Joern> sink.reachableByFlows(source).p
  """
┌─────────────────┬────────────────────────────┬────┬──────┬─────┐
│nodeType         │tracked                     │line│method│file │
├─────────────────┼────────────────────────────┼────┼──────┼─────┤
│MethodParameterIn│main(int argc, char *argv[])│5   │main  │X42.c│
│Call             │strcpy(buffer, argv[1])     │7   │main  │X42.c│
└─────────────────┴────────────────────────────┴────┴──────┴─────┘
  """

进入Joern终端后,依次执行上面4条命令,将会有污点传播路径的输出,其中:

  1. importCode("./c")​ 通过指定项目目录来导入c文件(Joern通过文件的后缀名来识别项目的语言类型);
  2. def source = cpg.method.name("main").parameter​ 指定方法中名字为main的函数的传入参数为source点;
  3. def sink = cpg.call.name("strcpy").argument​ 指定调用中名字为strcpy的函数的传入参数为sink点;
  4. sink.reachableByFlows(source).p​ 寻找从source点到sink点的数据流可达路径,并输出。

输出路径通过方框来显示:在文件X42.c的main函数中的第5行和第7行,main函数的argv到strcpy的第二个参数argv[1]之间有数据传播路径,因此存在栈溢出漏洞

以上展示,在Joern的shell中使用四条命令就可以完成一次污点传播分析,下面是实际漏洞复现中Joern的使用。

三、PHP漏洞复现

在使用Joern对真实项目中的漏洞进行复现时,关键是想办法定位出漏洞的source点和sink点,何处攻击可控,什么样的地方导致漏洞的出现以下通过漏洞的公开描述信息,以及漏洞的diff信息完成对source点的提取,在根据对应漏洞类型,完成对sink点的提取,最后使用Joern获取到对应漏洞数据流传播路径。

Piwigo is open source photo gallery software. Prior to version 13.8.0, there is a SQL Injection vulnerability in the login of the administrator screen. The SQL statement that acquires the HTTP Header User-Agent​ is vulnerable at the endpoint that records user information when logging in to the administrator screen.

Piwigo 是一款开源图片库软件。在 13.8.0 版之前,登录管理员后台存在 SQL 注入漏洞。登录管理员后台时,在记录用户信息的地方,获取 HTTP 头 User-Agent 的 SQL 语句存在漏洞

通过漏洞描述可得知,Piwigo在解析HTTP头中的User-Agent存在SQL注入。

通过询问大模型,php中User-Agent是靠什么传递的,得到以下结果:

在 PHP 中,User-Agent​ 是由客户端(通常是浏览器)在 HTTP 请求中发送的一个请求头。它用于标识发起请求的用户代理(即用户使用的浏览器或其他客户端软件)。获取 User-Agent 的方法在 PHP 中,你可以通过 $_SERVER​ 超全局变量访问 User-Agent​。具体方法如下:‍ _SERVER['HTTP_USER_AGENT']; echo $userAgent; ‍

通过模型的回答,可以得知$_SERVER['HTTP_USER_AGENT']​为该漏洞的source点:

Joern> cpg.call.code(".*\\$user_agent.*").code.l
val res131: List[String] = List(
...
  "$user_agent = $_SERVER[\"HTTP_USER_AGENT\"]",
...

在diff github漏洞修补中,将$user_agent​用函数pwg_db_real_escape_string​过滤危险字符。

因此可以定义这个漏洞的source点为$_SERVER['HTTP_USER_AGENT'];

Joern> importCode("Piwigo-13.0.0")
val res0: io.shiftleft.codepropertygraph.generated.Cpg = Cpg[Graph[841748 nodes]]

通过 importCode("Piwigo-13.0.0")​ 导入项目进行分析,可以看到最后生成的图中有841748个节点,定义source点和sink点就是对节点进行标记,reachableByFlows函数完成的就是标记为source的节点到标记为sink的节点之间的数据传播路径分析。

Joern> cpg.call.code(".*\\$_SERVER.*HTTP_USER_AGENT.*").code.l

val res5: List[String] = List(
  "$details[\"agent\"] = isset($_SERVER[\"HTTP_USER_AGENT\"]) ? $_SERVER[\"HTTP_USER_AGENT\"] : \"unknown\"",
  "isset($_SERVER[\"HTTP_USER_AGENT\"]) ? $_SERVER[\"HTTP_USER_AGENT\"] : \"unknown\"",
  "isset($_SERVER[\"HTTP_USER_AGENT\"])",
  ...
  "$_SERVER[\"HTTP_USER_AGENT\"]"
)

cpg.call.code(".*\\$_SERVER.*HTTP_USER_AGENT.*").code.l​ 将cpg的call类型节点中代码满足.*\\$_SERVER.*HTTP_USER_AGENT.*​类似于正则匹配的代码作为list输入,前半段cpg.call.code(".*\\$_SERVER.*HTTP_USER_AGENT.*")​表示节点匹配,code.l​表示代码输出。

将上面的code.l替换为argument,标记这些节点为source点。先对匹配到的节点进行输出是为确保标记为source的节点不为空。

Joern> def source = cpg.call.code(".*\\$_SERVER.*HTTP_USER_AGENT.*").argument

Joern> def sink = cpg.call.filter(node => node.code.contains("SELECT ") || node.code.contains("UPDATE ") || node.code.contains("INSERT ")).code(".* . .*").argument

Joern> sink.reachableByFlows(source).p #> "/tmp/query_result1"
'''
┌─────────────────┬─────────────────────────────────────────────────────────────────────────────────┬────┬────────────┬───────────────────────────────────────┐
nodeType         tracked                                                                          linemethod      file                                   
├─────────────────┼─────────────────────────────────────────────────────────────────────────────────┼────┼────────────┼───────────────────────────────────────┤
Identifier       $user_agent = $_SERVER["HTTP_USER_AGENT"]                                        562 pwg_activityinclude/functions.inc.php              
Identifier       $tmp8["user_agent"] = $user_agent                                                606 pwg_activityinclude/functions.inc.php              
Call             $tmp8["user_agent"] = $user_agent                                                606 pwg_activityinclude/functions.inc.php              
Block            $tmp8                                                                            598 pwg_activityinclude/functions.inc.php              
Identifier       $inserts[] = <empty>                                                             598 pwg_activityinclude/functions.inc.php              
Call             array_keys($inserts[0])                                                          610 pwg_activityinclude/functions.inc.php              
Call             array_keys($inserts[0])                                                          610 pwg_activityinclude/functions.inc.php              
MethodParameterInmass_inserts($table_name, $dbfields, $datas, $options)                           399 mass_insertsinclude/dblayer/functions_mysql.inc.php
Identifier       implode(",",$dbfields)                                                           428 mass_insertsinclude/dblayer/functions_mysql.inc.php
Call             implode(",",$dbfields)                                                           428 mass_insertsinclude/dblayer/functions_mysql.inc.php
Call             "\nINSERT " . $ignore . " INTO " . $table_name . "\n ("                          426 mass_insertsinclude/dblayer/functions_mysql.inc.php
Call             "\nINSERT " . $ignore . " INTO " . $table_name . "\n (" . implode(",",$dbfields) 426 mass_insertsinclude/dblayer/functions_mysql.inc.php
└─────────────────┴─────────────────────────────────────────────────────────────────────────────────┴────┴────────────┴───────────────────────────────────────┘
'''
  1. def source = cpg.call.code(".*\\$_SERVER.*HTTP_USER_AGENT.*").argument​,设置$_SERVER["HTTP_USER_AGENT"]​作为source点;

  2. def sink = cpg.call.filter(node => node.code.contains("SELECT ") || node.code.contains("UPDATE ") || node.code.contains("INSERT ")).code(".* . .*").argument​,标记SQL语句存在拼接的节点作为sink点, 没有用sql语句执行函数作为sink点的考虑是,不同项目的SQL查询语句定义的函数有区别,不好统一,二是SQL注入的漏洞模式就是有拼接(字符直接拼接或者sprintf拼接);

    1. cpg.call.filter(node => node.code.contains("SELECT ") || node.code.contains("UPDATE ") || node.code.contains("INSERT "))​,filter是过滤器,其机制是会遍历所有的节点,`filter(node=>(true|false))`当括号中返回true,代表对应的node会加入到输出;语句的效果是过滤得到所有call类型节点中包含"SELECT"UPDATE"INSERT 的节点,作为SQL语句特征;
    2. ​code(".* . .*") 表示获取语句中存在拼接的节点, . 作为拼接特征。
  3. ​sink.reachableByFlows(source).p #> "/tmp/query_result1" 将输出路径重定向存储于路径为 /tmp/query_result1 的文件中;

  4. 文件中的具体内容如方框中所示,攻击者可控数据 $user_agent 通过 $_SERVER 传入,在函数 mass_inserts 426行中存在SQL语句的拼接。

四、减小误报率

在实际使用中,为提高审计的效率,需要降低Joern的误报率,可以从两个方面考虑,首先一个方面是优化sink点的设置,另一个方面是对于数据传播路径中存在sanitizer的节点的路径进行过滤。

4.1 优化sink点

在PHP中SQL注入的漏洞修复手段有采用预编译的方法,其特征就是在SQL语句中加入 ? ,因此可以通过过滤语句中存在 ? 的语句,以减少误报率。

Joern> cpg.call.filter(node => node.code.contains("SELECT ") && node.code.contains(" . ") && node.code.contains("?")).code.l
...
  "\"SELECT \" . \"billing.billed FROM billing, code_types WHERE \" . \"billing.pid = ? AND \" . \"billing.encounter = ? AND \" . \"billing.activity = 1 AND \"",
  "\"SELECT \" . \"billing.billed FROM billing, code_types WHERE \" . \"billing.pid = ? AND \" . \"billing.encounter = ? AND \"",
  "\"SELECT \" . \"billing.billed FROM billing, code_types WHERE \" . \"billing.pid = ? AND \"",
...

为了过滤语句中存在 ? 的语句可以使用codeNot​方法

Joern> cpg.call.filter(node => node.code.contains("SELECT ") && node.code.contains(" . ")).codeNot(".*\\?.*").code.l
...
  "\"( SELECT \" . $referenceColumn . \" FROM \" . $table_name . \" WHERE \" . $column . \" = \" . $fragment->getFragment() . \" \"",
  "\"( SELECT \" . $referenceColumn . \" FROM \" . $table_name . \" WHERE \" . $column . \" = \" . $fragment->getFragment()",
  "\"( SELECT \" . $referenceColumn . \" FROM \" . $table_name . \" WHERE \" . $column . \" = \"",
...

codeNot(".*\\?.*")​代表过滤语句中存在"​"的节点,"​"是特殊字符所以要加"\\​"进行转义。

所以优化后的拼接造成的SQL注入漏洞模式的sink点大体如下:

def sink = 	cpg.call.filter(node => node.code.contains("SELECT ") && node.code.contains(" . ")).codeNot(".*\\?.*").argument

4.2 过滤含有sanitizer函数的路径

开发者对付sql注入和命令注入的常见手段是对用户可控输入进行特殊字符转义,类似于addslashes​和real_escape_string​以及escapeshellcmd​函數,不过滤这类的sanitizer​函数将花费较多的时间 咨询了郭师傅,Joern在数据传播路径的寻找中没有很好的设置sanitizer​的地方,所以考虑找出所有的path之后,过滤掉其中包含sanitizer函数的路径作为替换方案。

filter的机制是会遍历所有的path,当括号中返回true,代表对应的path会加入到输出:

sink.reachableByFlows(source).filter(path=>(true|false)).p

所以需要编写一个函数,当传入的Path中存在相应的sanitizer函数时返回false,以达到过滤的目的。和刘珂写了path_no_query_sanitizer​函数来进行过滤。

def path_no_query_sanitizer(path:Path):Boolean=(!path.elements.exists(f => f.code.contains("addslashes") || f.code.contains("real_escape_string")))
sink.reachableByFlows(source).filter(path=>path_no_query_sanitizer(path)).p
  1. path.elements.exists​代表一条path的成员变量elements包含由节点组成的list。exists​方法用于检查集合中是否至少有一个元素满足给定的条件。整个函数作用是,当节点的code中包含addslashes或者。real_escape_string字段时返回false。
  2. sink.reachableByFlows(source)​完成的是从source点到sink点的数据传播路径的寻找。
  3. filter(path=>path_no_query_sanitizer(path)).p​,将遍历所有找到的路径,并过滤掉包含sanitizer的传播路径。

五、总结

文章围绕如何使用Joern进行漏洞挖掘工作。介绍了Joern的特点及其背后的基本逻辑,借助官网的例子演示如何使用4条Joern shell命令完成污点传播分析,随后是对PHP CMS中真实存在的漏洞进行复现,最后针对漏洞挖掘中误报率高的问题,从两方面提出了解决思路。用户可以利用Joern强大的正则匹配机制,灵活定义sink点和source点完成污点传播分析,但是如何减少输出结果的误报率,提高分析审计的效率需要更多的规则。

六、参考链接

  1. Joern官方文档
  2. 入浅出Joern(一)Joern与CPG是什么?