0x00前言
这个漏洞是 国家电网公司信息与网络安全重点实验室 发现的,并在公众号上写了比较详细的分析,但是没有给出具体的poc。现在微信公众号已经将文章删除,但其他的安全公众号仍留有记录,看了一下分析过程,故想要跟着分析漏洞成因,编写poc,因此才有了这一篇分析文章。
0x01分析过程
1.客户端分析
访问http://ip:port/index.jsp 会提示有两种方法登录系统,一种是通过下载客户端、一种是使用浏览器访问。因为,浏览器访问的方式需要依赖不同用户设备上的java版本,IE浏览器,系统配置等环境因素,使用起来不是很方便,所以为了解决这些问题,用友提供了系统专用的UClient浏览器,可直接通过该浏览器访问nc而无需安装配置任何东西。
下载NClient并安装后,进入启动页面,可以选择添加应用。添加完后,在安装目录中可以看到所安装的应用。
点击app.esc发现直接启动了nc客户端,查看文件内容,发现执行了NClogin65.jar文件中的nc.starter.test.JStarter:
反编译NClogin65.jar,查看nc.starter.test.JStarter,调用nc.starter.ui.NCLauncher#main主要是与远程服务端通信,生成uI之类的操作。主要的通信代码并不在该jar包,继续在目录中寻找。
在nc_client_homeNCCACHECODE目录的子目录中有很多的jar包,其中external目录上的jar包是客户端通信的逻辑代码。
随便点了一两个包,发现类还不少,如果逐个看的话很耗费时间,效率还不高,看了分析的文章发现用javaagent把调用的类都打印出来的方法可以解决这个问题,具体原理感兴趣的可以去网上搜索相关的文章,具体可以代码可以参考javaagent项目中使用。
之前在app.esc文件中可以看到启动jar的jvm配置信息,加上我们的javaagent的jar包,这样才能正常加载自己的javaagent。
启动后可以看到把所有调用的类都输出出来了。
这里可以配合idea的远程调试,添加参数jdwp:
-agentlib:jdwp=transport=dt_socket,server=y,address=8000
打开客户端,随便输入账号密码点击登录后,查看log可以看到有login字眼的类:
找到对应的nc.login.ui.LoginUISupport类,这个类方法很多,我一开始想的是通过一般登录都是带有request,response的,所以我就搜了request的关键字,在一个看起来比较像处理登录请求的方法下了断点,点击登录后果然是在这个地方断下来了。该方法主要是将输入的用户名、密码等值,在requestd类的变量赋值。
执行getInstance(),获取NCLocator的实例,并执行实例的lookup方法。
跟进nc.bs.framework.common.NCLocator#getInstance(java.util.Properties)
刚启动的时候locatorMap为空,则会在下面的判断分支中,创建RmiNCLocator实例并将该实例存放到locatorMap中。
locator = (NCLocator)locatorMap.get(key);
if (locator != null) {
return locator;
} else {
if (!isEmpty(locatorProvider)) {
locator = newInstance(locatorProvider);
} else if (!isEmpty(svcDispatchURL)) {
locator = newInstance("nc.bs.framework.rmi.RmiNCLocator");
} else {
locator = getDefaultLocator();
}
locator.init(props);
locatorMap.put(key, locator);
return locator;
}
获取到RmiNCLocator实例后,跟进到lookup方法:
//nc.bs.framework.rmi.RmiNCLocator#lookup
public Object lookup(String name) throws ComponentException {
Object result = null;
try {
result = this.remoteContext.lookup(name);
return result;
} catch (Throwable var4) {
if (var4 instanceof FrameworkRuntimeException) {
throw (FrameworkRuntimeException)var4;
} else {
throw new ComponentException(name, "Component resolve exception ", var4);
}
}
}
调用了this.remoteContext.lookup(name);
,继续跟进:
//nc.bs.framework.rmi.RemoteContextStub#lookup
public Object lookup(String name) {
Object so = this.proxyMap.get(name);
if (so != null) {
return so;
} else {
ComponentMetaVO metaVO = this.getMetaOnDemand(name);
if (metaVO == null) {
throw new ComponentNotFoundException(name, "no remote componnet found from server");
} else {
so = this.proxyMap.get(metaVO.getName());
if (so != null) {
return so;
} else {
RemoteAddressSelector ras = new GroupBasedRemoteAddressSelector(this.getRealTarget(metaVO), this.getServerGroup(metaVO));
so = RemoteProxyFactory.getDefault().createRemoteProxy(RemoteContextStub.class.getClassLoader(), metaVO, ras);
this.proxyMap.put(metaVO.getName(), so);
return so;
}
}
}
}
可以看到先从proxyMap中查看是否存在参数name的方法,如果存在则直接返回,不存在则进入另外的分支。因为这里我是刚启动的,所以该方法是不存在的,也可以直接将so赋值为null,进去到下面的分支。
跟进nc.bs.framework.rmi.RemoteContextStub#getMetaOnDemand
,又可以看到调用了this.remoteMetaContext.lookup(name);
方法,这个变量remoteMetaContext是nc.bs.framework.server.RemoteMetaContext
类,那么这个类是怎么来的呢?
这里可以去看nc.bs.framework.rmi.RemoteContextStub#RemoteContextStub
的构造函数,第65行创建了一个代理,并赋值到this.remoteMetaContext
:
了解java代理的都知道不管用户调用代理对象的任何方法,该方法都会调用处理器(即Proxy.newProxyInstance()的第三个参数)的invoke方法,这里即是nc.bs.framework.rmi.RemoteInvocationHandler#invoke
。不懂的可以先看这里。
那么现在回到上面,跟进this.remoteMetaContext.lookup(name);
,果然进入到了invoke方法。经过一番判断
执行this.sendRequest(method, args)方法。
//nc.bs.framework.rmi.RemoteInvocationHandler#sendRequest(java.lang.reflect.Method, java.lang.Object[])
public Object sendRequest(Method method, Object[] args) throws Throwable {
InvocationInfo ii = this.newInvocationInfo(method, args);
Address old = null;
int retry = 0;
ConnectorFailException error = null;
do {
Address target = this.ras.select();
if (old != null) {
Logger.error("connect to: " + old + " failed, now retry connect to: " + target);
if (old.equals(target)) {
try {
Thread.sleep(this.retryInterval);
} catch (Exception var13) {
}
}
}
this.restoreToken(ii, target);
try {
Object var8 = this.sendRequest(target, ii, method, args);
return var8;
} catch (ConnectorFailException var14) {
++retry;
old = target;
error = var14;
} finally {
this.storeToken(ii, target);
}
} while(retry < this.retryMax);
throw error;
}
继续跟进this.sendRequest(target, ii, method, args);
,在第182行将ii序列化输出,发送到http://server:port/ServiceDispatcherServlet ,并获取服务端返回的结果反序列化,回显到客户端。
到此客户端的处理流程大致分析完成,看到这里大家可能有会对上面客户端将类序列化发往服务端,那服务端肯定要反序列化呀,会不会有问题?
2.服务端分析
先来分析jndi注入的形成
在nc.bs.framework.comn.serv.CommonServletDispatcher#doPost
第38行下断点。
跟进this.rmiHandler.handle(new HttpRMIContext(request, response));
,跟进后在第85行继续跟进this.doHandle(rmiCtx);
。
在第153行出现处理客户端提交内容的,继续跟进:
在第282行可以看到直接将输入流的内容反序列化了,代码执行过程中完全没有任何的过滤,确实存在触发反序列化漏洞,这里先不管,继续往下。
这里注意的是第286行将反序列化后的类赋予到抽象类nc.bs.framework.rmi.server.AbstractRMIContext#invInfo
的invInfo变量里,这个变量在下面用到。
回到刚才的第二个断点,跟进result.result = this.invokeBeanMethod(rmiCtx);
,这里第333行就是上面说到的invinfo变量,实际就是反序列化后的类。
这里有两个分支,不管是哪个都存在jndi注入,因为这里lookup的参数service是可控的,所以必然存在漏洞。
3.效果演示
这里就不给poc了,如果看懂了上面的过程其实也不难,实际就是构造一个InvocationInfo类,并将servicename的值设置为远程恶意类,序列化后发送到服务端触发jndi注入即可。
4.前面的反序列化
上面发现的反序列化根本都没有过滤的,为啥还要这么麻烦要jndi注入呢,直接反序列化不香嘛?看了web的依赖环境,commons-collections3.2,那不是现成的利用嘛。
直接ysoserial生成恶意类发送,弹计算器。
整个调用栈如下:
0X02 最后说几句
漏洞过程并不是太复杂,应该不难理解的。因第一次写文章且本人水平有限,故有错误的地方望大佬们扶正,手下留情。