前两天的 log4j 漏洞引起了安全圈的震动。虽然他是一名二进制选手,但为了融入每个人的新年氛围,他决定打破舒适区进行研究 JNDI 注入漏洞。
JNDI 101
首先,第一个问题是什么? JNDI,它的作用是什么?
根据官方文档,JNDI 全称为 ,即 Java 名称与目录界面。虽然有点抽象,但我们至少知道它是一个界面;下一个问题是,Naming 和 Directory 这是什么意思?许多相关信息对其含糊不清,但实际上官方对其有详细的解释。
Naming
就直译而言,它是名称,但在更多情况下,它是与 一起使用,简单来说就是。正如数学理论所说: 一般的成本是抽象的。名称服务在计算机系统中很常见,如:
- 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 详见后文。
在名称系统中,有几个重要的概念。
: 表示名称与对应对象的绑定关系,如文件名绑定到文件系统中的相应文件 DNS 与对应的域名绑定 IP。
: 在上下文中,一组名称对应于对象的绑定关系,我们可以在指定的上下文中找到名称对应的对象。例如,在文件系统中,目录是上下文,可以在目录中找到文件,子目录也可以称为子上下文 (subcontext)。
: 在实际的名称服务中,有些对象可能无法直接存储在系统中,然后以引用的形式存储,可以理解为 C/C 中的指针。参考包括获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这是一个引用,内核根据引用值找到磁盘的相应位置和读写偏移。
Directory
名称服务相对容易理解,那么什么是目录服务呢?简单地说,目录服务是名称服务的扩展。除了名称服务中现有名称与对象的相关信息外,还允许对象拥有属性(attributes)因此,我们不仅可以根据名字找到信息(lookup)对象(并获得相应的属性)也可以根据属性值进行搜索(search)对象。
以打印机服务为例,我们可以根据打印机名称获取打印机对象(打印机对象(引用),然后打印;同时,打印机具有速率、分辨率、颜色等,用户可以根据打印机的分辨率搜索相应的打印机对象作为目录服务。
目录服务(Directory Service)在目录中提供对象(directory objects)添加、删除和检查属性。一些典型的目录服务包括:
- NIS: Network Information Service,Solaris 用于搜索系统相关信息的目录服务;
- Active Directory: 为 Windows 域网设计包括域名服务、证书服务等多种目录服务;
- 其他基于 LDAP 协议实现的目录服务;
总之,目录服务也是一种特殊的名称服务。关键区别在于搜索通常用于目录服务(search)操作来定位对象,而不是简单地根据名称搜索(lookup)去定位。
如下文没有特别说明,名称服务和目录服务将统称为目录服务。
API
根据以上介绍,我们知道目录服务是集中网络应用的重要组成部分。使用目录服务可以简化服务管理验证逻辑,集中存储和共享信息。 Java 除了常规的名称服务(如使用) DNS 分析域名),另一种常用的方法是使用目录服务作为对象存储系统,即存储和获取目录服务 Java 对象。
例如,对于打印机服务,我们可以在目录服务中找到打印机,并获得基于此的打印机对象 Java 实际打印对象。
为此,就有了 JNDI,即 Java 该应用程序通过该接口与特定的目录服务面与特定的目录服务进行交互。在设计上,JNDI 实现独立于特定的目录服务,为不同的目录服务提供统一的操作接口。
JNDI 架构主要包括两部分,即 Java 应用层接口和 SPI,如下图所示:
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
- javax.naming.directory
- javax.naming.event
- javax.naming.ldap
- javax.naming.spi
最重要的是 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
第一个就是 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)。
拓展阅读:
- Java™ Remote Method Invocation API
- Getting Started Using Java™ RMI
LDAP
LDAP 既是一类服务,也是一种协议,定义在 RFC2251(RFC4511) 中,是早期 X.500 DAP (目录访问协议) 的一个子集,因此有时也被称为 。
LDAP Directory 作为一种目录服务,主要用于带有条件限制的对象查询和搜索。目录服务作为一种特殊的数据库,用来保存描述性的、基于属性的详细信息。和传统数据库相比,最大的不同在于目录服务中数据的组织方式,它是一种有层次的树形结构,因此它有优异的读性能,但写性能较差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。
LDAP 的请求和响应是 格式,使用二进制的 BER 编码,操作类型(Operation)包括 Bind/Unbind、Search、Modify、Add、Delete、Compare 等等,除了这些常规的增删改查操作,同时也包含一些拓展的操作类型和异步通知事件。
完整的协议介绍可以参考对应的 RFC 文档,我们这里直接通过抓包去直观的感受 LDAP 请求数据:
上述截图包含了客户端对于 LDAP 服务端的两次请求,一次绑定操作和一次搜索操作,其中搜索操作返回了两个 ,后一个类型为 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,组成域名的部分,比如域名 的一条记录可以表示为 ,从右至左逐级定义;
- 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
CORBA 是一个由 Object Management Group (OMG) 定义的标准。在分布式计算的概念中,ORB(Object Request Broker) 表示用于分布式环境中远程调用的中间件。听起来有点拗口,其实就是早期的一个 RPC 标准,ORB 在客户端负责接管调用并请求服务端,在服务端负责接收请求并将结果返回。
CORBA 使用接口定义语言(IDL) 去表述对象的对外接口,编译生成的 stub code 支持 Ada、C/C++、Java、COBOL 等多种语言。其调用架构如下图所示:
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 ...");
}
}
服务端主要做几件事:
- 实现 HelloPOA 的接口,也就是我们之前在 IDL 中定义的接口;
- 根据参数初始化 ORB 对象,这一步会通过网络连接 ORB 服务器,后面会讲到;
- 将本地实现的 Hello Impl 类转化为引用并绑定到 ORB 服务器对应
Hello
的名称中; - 循环等待客户端调用;
接着我们看客户端的代码:
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);
}
}
}
主要操作为:
- 通过命令行参数去初始化 ORB 对象,这个和服务端一致,也会连接到 ORB 服务器;
- 在 ORB 服务中查找(解析)名称 Hello,并通过
HelloHelper.narrow
转换为 Hello 对象; - 通过获得的 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 中查看对应的流量:
流量主要分为两个部分,id 为 23 及其之前的为服务端启动的流量,后面的是客户端的启动以及请求流量。通用的部分为 op=get
和 op=_is_a
,在解析 ORB 类参数并连接 时触发。
服务端注册并绑定自身服务是通过:
op=to_name
: 由ncRef.to_name(name);
触发,将字符串名称转换为 对象;op=rebind
: 由ncRef.rebind(path, href)
触发,将本地的对象绑定到目录服务中;
客户端查询服务并进行调用:
op=resolve_str
: 由于ncRef.resolve_str(name)
触发,根据字符串查询服务并转换为本地可调用的对象;op=sayHello/shutdown
: 发起实际的 RPC 调用;
这里有一个关键点是在服务端 to_name
的请求所对应的响应中,返回的是 IOR 结构对象,即上图高亮的部分。这个结构中包含了远程类的实现代码,在上面客户端的输出中,返回的 helloImpl 打印出来也是 ,正是 resolve_str
的返回,和服务端 rebind
的返回结果是一致的。
的全称是 ,即可互操作对象引用,其中包含了用于构建远程对象所需的必要字段,比如远程 IIOP 地址、端口信息(这个端口不是 1050,而是动态生成的)等。
其他一些常见的名词解释如下:
- : 由 IDL 编译而成的客户端模板代码,开发者通过调用这些代码来实现 RPC 功能;
- : Portable Object Adapter,可拓展对象适配器,简单来就是 IDL 编译而成的服务端模板代码,开发者通过继承去实现对应的接口来实现 RPC 的服务端功能,参考上面代码中的
HelloPOA
; - : General Inter-ORB Protocol,ORB 互传协议,是一类抽象协议,指定转换语法和消息格式的标准集;
- : Internet Inter-ORB Protocol,ORB 网间传输协议,是 GIOP 在互联网(TCP/IP)的特化实现;
- : RMI over IIOP,由于 RMI 也是 Java 中常用的远程调用框架,因此 Sun 公司提供了针对这二者的一种映射,让使用 RMI 的程序也能适用于 IIOP 的协议;
拓展阅读:
- Common Object Request Broker Architecture
- Java IDL: The “Hello World” Example
- Java CORBA - Seebug
JNDI 注入
背景知识总算介绍完了,接下来开始深入 JNDI 注入的原理。从上面介绍的三个 Service Provider 我们可以看到,除了 RMI 是 Java 特有的远程调用框架,其他两个都是通用的服务和标准,可以脱离 Java 独立使用。JNDI 就是在这个基础上提供了统一的接口,来方便调用各种服务。
Normal 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();
}
}
}