MySQL严格模式关闭时的安全风险与技术利用详解

本文深入分析了MySQL严格SQL模式关闭时可能引发的安全风险,通过具体攻击场景演示了如何利用编码不匹配与字符替换机制绕过输入验证,实现文件枚举、正则表达式绕过、参数注入等攻击。

当MySQL严格SQL模式关闭时会发生什么?

本文展示了一些攻击示例,这些攻击可以滥用MySQL在严格SQL模式禁用时的行为,尤其是在当前编码中字符串字符无效的情况下。当应用程序的编码(例如UTF-8)比数据库的编码(例如ASCII)更宽时,就会发生这种情况。

目录

  • 什么是MySQL严格SQL模式?
  • 设置与检查
  • 观察
  • 攻击场景
  • 利用MySQL严格SQL模式回退机制

什么是MySQL严格SQL模式?

严格模式控制MySQL如何处理数据更改语句(如INSERT或UPDATE)中的无效或缺失值。本文详述的情况是值超出范围。当值在当前编码中无效时会发生什么?

在此情况下,严格模式的过度简化:

严格模式 开启 严格模式 关闭
错误 警告(静默)
未修改任何内容 插入“调整后”的值(“最接近的值”)

此时,你已经猜到了可能出什么问题。

设置与检查

实际上,并没有一个简单的“严格模式”是“开启”或“关闭”。存在一个名为sql_mode的系统变量,它包含一个值数组。如果该数组包含STRICT_TRANS_TABLESSTRICT_ALL_TABLES值之一,或者组合模式TRADITIONAL,那么MySQL就处于“严格模式”。

MySQL 9.1的文档声称sql_mode的默认值如下:

1
ONLY_FULL_GROUP_BY STRICT_TRANS_TABLES NO_ZERO_IN_DATE NO_ZERO_DATE ERROR_FOR_DIVISION_BY_ZERO NO_ENGINE_SUBSTITUTION

在其他版本或替代后端引擎(如MariaDB或Percona Server)上,默认值可能不同。@@sql_mode系统变量允许查询当前值。

1
2
[u]> SELECT @@sql_mode\G;
@@sql_mode: STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION

安装程序可能在安装过程中配置SQL模式。因此,即使严格模式默认启用,XAMPP、CMS等也可能静默禁用它。 禁用严格模式(以及其他所有模式)的暴力方法是:

1
SET sql_mode='';

观察

让我们创建一个使用ASCII编码的表,故意插入无效值,看看严格模式开启和关闭时会发生什么。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- 创建一个故意使用窄空间编码列的表
CREATE TABLE uni_sandbox (
  id INT AUTO_INCREMENT PRIMARY KEY,
  data VARCHAR(255) CHARACTER SET ascii
);

-- 启用严格模式
SET sql_mode='STRICT_ALL_TABLES';
INSERT INTO uni_sandbox (data) VALUES ('I ♥ Unicode');
-- => ERROR 1366 (22007): Incorrect string value: '\xE2\x99\xA5 Un...' for column `unicode8`.`uni_sandbox`.`data` at row 1

-- 禁用严格模式
SET sql_mode='';
INSERT INTO uni_sandbox (data) VALUES ('I ♥ Unicode');
SELECT * FROM uni_sandbox\G;
-- => data: I ? Unicode

严格模式开启时,触发错误;严格模式关闭时,数据被插入,但♥被替换为?。Oracle MySQL或MariaDB文档说“将无效值转换为最接近的有效值”,但没有确切解释其工作原理。实际上,超过列大小的字符串会被截断,整数会四舍五入到最接近的值,但这仅适用于溢出的值。对于无效值(无法在编码中表示),每个字节都会被替换为?。

攻击场景

想象以下场景:有一个Web应用程序,正在严格检查用户输入。一种常用的通用验证方法是使用正则表达式。无论使用何种语言,几乎所有的正则表达式引擎都支持POSIX字符类。但多年前,随着Unicode支持的普及,许多引擎扩展了这些类的范围以支持Unicode(原本仅限于ASCII)。因此,例如,它不仅匹配ASCII范围内的字母数字字符(A-Za-z0-9),默认情况下还会匹配来自相应Unicode类别(如字母(L)和数字(N))的字符。

以Ruby中的字符“拉丁字母双齿敲击音 ʭ”为例。

1
2
/[[:alnum:]]/.match('ʭ')
# => #<MatchData "ʭ">

其他一些语言,如JavaScript,也通过实现Unicode属性和类别选择器(\p{…})来实现非POSIX字符类选择器,您可以直接匹配Unicode属性(例如,\p{Ll}Lowercase_Letter表示小写字母)或别名属性(例如,\p{Alpha}表示字母,匹配字母和字母数字)。

1
2
"ʭ".match(/\p{Alpha}/u)
// Array [ "ʭ" ]

另一方面,如果数据库使用“窄空间”编码,如ASCII、CP-1252,甚至是部分遗留实现,如与禁用严格模式(例如CMS安装自动执行此操作)结合的utf8mb3,这将导致一些Unicode字符通过安全检查,但由于在数据库编码中无效,它们最终会被替换为?。

在这种情况下,可以想到一些在现实生活中可行的攻击场景和安全绕过方法。

利用MySQL严格SQL模式回退机制

场景摘要

本节介绍的所有攻击都将基于以下场景:应用程序的安全检查仅允许字母数字字符(包括Unicode字符),但MySQL数据库使用ASCII或类似的编码,其中Unicode字符无效,并且严格SQL模式已禁用。

Shell通配符扩展

Bash不支持正则表达式,但可以执行文件名扩展,即通配符扩展。Bash将扩展称为通配符的字符。最著名的是*,它匹配任何长度的任何字符串,但还有?,它恰好匹配一个字符(与正则表达式量词?不同)。例如,在Unix文件系统的根目录中,/???t将匹配/boot/root目录。

1
2
$ ls -d /???t
/boot  /root

假设由于不安全的直接对象引用而存在本地文件泄露,但文件使用UUIDv4重命名(例如2a0f6947-bd44-449e-94fe-82ebc3ecf115.txt)。理论上,您可以读取所有其他用户的文件,但实际上不行,因为无法暴力破解标识符。

但是,如果您请求读取ʭʭʭʭʭʭʭʭ-ʭʭʭʭ-ʭʭʭʭ-ʭʭʭʭ-ʭʭʭʭʭʭʭʭʭʭʭʭ.txt会怎样?这将允许您列出所有文件,因为ʭ(拉丁字母双齿敲击音)以及其他成千上万个字符是字母类型的Unicode字符,将通过字母数字安全检查。之后,当存储在MySQL中时,它不会被识别为有效的ASCII字符,因此由于严格SQL模式被禁用,它会回退为?。然后,在Bash中,?将扩展为任何单个字符。因此,find命令将列出目录中的所有文件,而不仅仅是一个文件,从而绕过应用程序安全检查以及UUID标识符的使用。

正则表达式量词

对于正则表达式,?是一个量词字符,它附加在表达式之后,表示必须出现零次或一次。

例如,正则表达式filename\d?\.txt将匹配以下任何一个文件名(无数字或一个数字),但不匹配filename10.txt,因为它有两个数字。

1
2
3
4
filename.txt
filename1.txt
...
filename9.txt

现在想象一个Python应用程序,它根据类似以下的用户输入返回文件。

1
2
3
4
5
import re

username = User.username # 从数据库获取的用户输入
check(username, lib.alphanum) # 一些Unicode字母数字检查
re.findall(f'confidential-{username}\.pdf', 'list-files-fetched-fromFS-or-DB', re.IGNORECASE)

因此,类似于之前的情况,注册一个用户名为aʭbʭ…yʭzʭ0ʭ1…8ʭ9ʭ将被转换为a?b?…y?z?0?1?…8?9?,这将允许在插入到正则表达式中后匹配任何ASCII字母和数字一次。如果目标模式长度为五个字符,则此模式必须重复5次。有效负载会非常长且效率低下,但会起作用。

1
2
3
4
5
6
import re
import string

payload = "".join(map(lambda i: i + '?', string.ascii_lowercase + string.digits)) * 5
re.findall(f'confidential-{payload}\.pdf', 'confidential-noraj.pdf', re.IGNORECASE)
# => ['confidential-noraj.pdf']

当然,在允许使用连字符和括号,且只需绕过?限制的场景中,这会更容易。

查询参数

在用户只能写字母数字字符的上下文中,注入?的能力也可能允许在URL中注入查询参数。

例如,如果Web应用程序内部根据用户名或任何存储在数据库中的经过过滤的用户输入构建URL,则用户可能能够添加诸如debugadmin之类的查询参数,从而获得未授权访问或敏感信息。下面的部分Python示例可以说明可能的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from flask import Flask, redirect, url_for
import urllib.parse

app = Flask(__name__)

@app.route('/data/<username>')
@internal # 通过用户名获取用户数据的内部路由
def profile_name(username):
    # 根据用户名从文件系统获取一些数据
    data = "user_secret" if request.args.get("debug") != None else "user_public_stuff"
    return data

@app.route('/profile/name/<userid>')
@internal # 将用户ID转换为用户名的内部路由
def profile_data(userid):
    user = User(userid).username # 通过从数据库获取数据实例化用户类
    return redirect(urllib.parse.unquote(url_for('profile_name', username=user)))

@app.route('/api/public/profile/<userid>')
@auth # 获取个人资料信息的公共路由
def api_profile(userid):
    return redirect(url_for('profile_data', userid=userid))

@app.route('/api/public/profile/name/<userid>')
@auth # 设置用户名的公共路由
def set_name(userid):
    username = request.args.get("name")
    check(username, lib.alphanum) # 一些Unicode字母数字检查
    User(userid).username = username
    return True

if __name__ == '__main__':
    app.run(debug=True)

根据之前的观察,数据库会将诸如norajʭdebug的用户名转换为noraj?debug。当然,无法注入=将阻止向参数传递值。但是,根据使用的Web框架或编程语言,应用程序可能只检查参数是否存在。

WAF绕过

除了之前设置的场景之外,间接注入?当然也有助于绕过WAF。例如,在存储文件二进制大对象或模板的数据库中,攻击者获得了写访问权限,WAF将阻止包含<?php的有效负载。在这种情况下,发送<ʭphp可能会绕过检测规则,并由于严格SQL模式回退行为,在之后转换为原始有效负载。

现实故事

很久以前(2012年),行星排成一线,导致了疯狂的漏洞。在那个史前时代,Wordpress在安装过程中禁用了严格SQL模式。另一方面,MySQL连锁出现了糟糕的设计缺陷。MySQL中UTF-8的唯一实现称为utf8(现在称为utf8mb3),它存在只能处理1到3字节字符的问题(而有效的UTF-8最多为4字节)。utf8mb3非常具有误导性,因为声称MySQL支持UTF-8会让人们认为UTF-8支持是100%完整的,而不仅仅是一个部分实现。另一个危险行为是,当严格SQL模式被禁用时,它不会像现在这样用?替换无效字符。相反,无效字符以及字符串的其余部分被纯粹简单地删除了!因此,任何有效的UTF-8 4字节字符在utf8mb3中被判定为无效,并触发其后任何内容的截断。

这3个主要缺陷的组合在2014年被Cedric’s Cruft利用,在Wordpress核心评论功能中获得了存储型XSS。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计