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 名称如下:

sh

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 接口,用户可以透明地调用远程打印服务,伪代码如下所示:

java

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 主要由三部分组成,分别是接口、服务端和客户端。

接口定义:

java

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

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

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

服务端:

java

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 的监听端口,后面会讲到。

客户端代码如下:

java

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 对象,从而进行远程调用。

编译:

sh

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

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

sh

$ cd out
$ rmiregistry 1099

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

sh

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

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

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

sh

$ 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 的对象,最终返回匹配记录项。

python

@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 中常见的属性定义如下:

text

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 描述为:

text

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 代码:

js

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

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

sh