从一个绕过长度限制的 XSS 中,我们能学到什么?

从一个绕过长度限制的 XSS => IDN => IDNA => punycode => NFKC,长文警告!

一个绕过长度限制 XSS

最近收到了一个白帽子提交的 xss,简单来说就是某个展现的值可被 xss,但是这个业务限制了可输入值的长度是 length <= 4,所以 xss payload 是非常受限的,就算是引用外部 js,域名也不会这么短(后缀至少是 2 位,加上一个 . 就三位了,再加前缀以及闭合的字符肯定超过了),所以一直没人发现。而这个白帽子利用 Unicode 域名:⑭.₨(长度为 3,加上闭合 ')绕过了这一限制。对于 ⑭.₨,浏览器会将其转为 14.rs 进行访问,从而带入 payload 完成 xss。结合前几期那篇利用不可见字符过人的 webshell 来看,Unicode 再次展现了其威力。

浏览器到底怎么了?

经过搜索,这一特性至少在 2016 年就被人用来挖洞了:

虽然上面的推特里加了一个 in MS Edge,但经过测试,我发现 Chrome、Firefox、Safari 都会这么处理。浏览器到底怎么了?为什么要这样处理?一切的一切都要从 IDN 说起。

IDN 与 IDNA

许多年前,互联网域名只能由 ascii 字母 a-z、数字和其他一些字符组成。而我们之所以发明域名 + DNS 解析代替 ip 地址就是为了好记。随着整个世界的国际化,以英文为主的域名已经不够满足其他语种的人的需要,国际化域名(Internationalized Domain Name,即 IDN)应运而生。IDN 是指部分或完全使用特殊的文字或字母(包括中文、拉丁字母等等非英文字母)组成的域名。

大家如果现在去申请域名,就可以选择中文的前缀或者后缀:

前缀

后缀

那么问题来了,就目前而言,DNS 服务器的解析都由 ascii 码交换,所以 DNS 服务器上并不支持直接的中文域名解析,那怎么办呢?后来,经过专家们对多份方案的讨论与比较之后,IDNA(Internationalizing Domain Names in Applications)被采纳为正式标准(提案 rfc3490:https://tools.ietf.org/html/rfc3490 ),说明了应用程序在遇到非 ASCII 字符的时候,应该如何处理。

提到 IDNA,就不得不提 punycode

Punycode

punycode 的提案见:https://datatracker.ietf.org/doc/html/rfc3492

目前,所有中文域名的解析都需要转成 punycode,然后通过 DNS 协议解析 punycode,这样 DNS 协议便能够支持非 ASCII 字符了(你可以理解为把非 ASCII 字符的 Unicode 转为特定的 ASCII 码)。

这里给一个维基百科上 IDNA 处理流程的例子:
Bücher.example 进行 IDNA 编码(此域名具有两个标签,Bücherexample):

  1. 第二个标签 example 是纯 ASCII,保持不动。
  2. 第一个标签 Bücher,先将其转换小写的 bücher(其实还有其他操作,可以搜索 Nameprep 算法详细了解,这里就不多说了),然后转为 punycode,得到 bcher-kva。接下来加上xn--前缀,得到xn--bcher-kva
  3. 最后拼接得到 xn--bcher-kva.example

当然你也可以用 python:

注意,转为 punycode 只是 IDNA 流程中的其中一步。 如果你不能理解,可以看下这个例子:

当然,现在的浏览器都完美支持 Unicode 域名,会自动转码,甚至是 curl 都支持 IDN 域名:

当然,这个并非百度官方所有。于是,这就引发了另一个问题,Unicode 七七八八的字符多了去了,要是我申请一个与官方网站视觉相似的域名,岂不是就可以美滋滋地钓鱼?

Let's go fishing!

示例:
https://www.аррӏе.com/https://www.аррle.com/

这就是著名的同形异义词攻击

什么?你一眼就能看出来 l 不一样?那么这个呢?
https://аpple.comhttps://apple.com

如果你能看出来两个域名的 a 不一样,请您立即联系您所在当地的超能力者协会。

对抗 IDN 钓鱼,浏览器做了什么?

看了上面的例子,你可能会想,这有什么大不了的呢?既然非 ascii 字符,经过 IDNA 协议的流程之后,会转为 punycode,那我统一在浏览器的地址栏显示 punycode 不就得了?
访问 https://www.аррӏе.com/ 显示如下:

思路不错,但是别忘了我们为什么要发明 IDN?就是为了方便其他语种的人使用,提升他们的体验。如果在地址栏显示 punycode,那还不如 ascii 字符来的直观。开倒车,咱们不干。

访问 http://百度.公司 显示如下:

那么该怎么办呢?以 Chrome 为例,他们在这里(https://www.chromium.org/developers/design-documents/idn-in-google-chrome )详细介绍了什么时候会显示 punycode,什么时候显示 Unicode 字符。

Unicode 真的是太多了,所以 Chrome 需要利用很多方式去判断一个域名该用哪种方式显示。这样虽然可能会存在漏掉的情况,但是经过这么多年的发展,Chrome 处理方式已经比较完善了,如果你能发现一个字符绕过了他们的判断,那么这个漏洞的价值保守估计 $2k。

这里顺便吹一波 Chrome,体验真的很好:

(什么叫国际浏览器啊?战术后仰.gif)

相比 Chrome,其他的例如 Safari 处理方式就很粗暴了,不在白名单里的域名直接显示 punycode。

哦我的上帝,看看这个处理方式,什么是用户体验?什么是钓鱼?Safari:“你搁谁这卡 bug 呢?”

IDNA 带来的另一个问题

假如现在有一个终极浏览器,搞定了什么时候显示 punycode 什么时候显示 unicode,彻底解决了钓鱼的问题。也还需要面对另一个问题:Unicode 规范化带来的安全隐患,这就回到了最开始的那个 xss。

前面和大家提到了 IDNA 的 Nameprep 算法,相关提案是 rfc3491,里面有关于 unicode 规范化的说明:https://tools.ietf.org/html/rfc3491#section-4。

而大家初二的时候就知道,Unicode 规范化有四种形式:

  1. NFC: Normalization Form Canonical Composition
  2. NFD: Normalization Form Canonical Decomposition
  3. NFKC: Normalization Form Compatibility Composition
  4. NFKD: Normalization Form Compatibility Decomposition

来看一个例子:

可以看到,⑭.₨ 的规范化有 2 种结果,而后面两种是不是很像浏览器处理的逻辑?rfc3491 采用的正是 NFKC!所以,浏览器采用的 IDNA 协议所使用的 unicode 规范化标准正是 NFKC

最后,如果你真的很想深究一下 IDNA 协议到底是怎么规定一个 unicode 域名如何转为 punycode 形式的域名,以及在转为 punycode 之前做了什么事情,我十分建议你去看一下 python 的 encodings 源码,路径是:lib/python3.7/encodings/idna.py,所有的答案都在这里,就看你愿不愿意探索了。

利用场景

根据上面所述,IDNA 基于的 NFKC 本身就会导致各种问题,加上 IDNA 的一些流程,可能造成的安全问题就更多了,例如:

  1. 绕过字符过滤
  2. 绕过应用对数据的限制,例如绕过长度限制、绕过黑名单字符过滤、引入恶意字符导致各种注入(例如远古版本的 sqlserver)等等
  3. 可能造成非预期的跨域问题
  4. 越权
  5. 目录穿越
  6. ssrf
  7. 钓鱼
  8. ...

举几个例子:

绕过字符过滤

Python3.x 支持了 Unicode 变量名,而根据官方文档,解释器在做代码解析的时候,会对变量名进行规范化,使用的算法是 NFKC:

https://docs.python.org/3/reference/lexical_analysis.html#identifiers

这样我们就可以利用特殊字符来绕过关键字符串的过滤,从而执行指定的函数,例如:

1
eval == ᵉval

根据同样的思路,我们可以找到以下字符:

1
ᵃᵇᶜᵈᵉᶠᵍʰᵢʲᵏˡᵐⁿᵒᵖ𝐪ʳˢᵗᵘᵛʷˣʸᶻᴬᴮCᴰᴱFᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾQᴿ𝖲ᵀᵁⱽᵂⅩ𝖸Z

当然,这里只列出了其中一种替换情况,Unicode 里还有很多这种规范化之后变成正常字母的字符。

绕过长度限制

1
2
3
4
5
In [47]: print(unicodedata.normalize('NFKC', '⑭.₨'))
14.Rs

In [48]: len('⑭.₨')
Out[48]: 3

SQL 注入

1
2
3
4
5
6
In [20]: print(unicodedata.normalize('NFKC', '' or '1'='1'))
' or '1'='1
In [21]: print(unicodedata.normalize('NFKC', '" or "1"="1'))
" or "1"="1
In [22]: print(unicodedata.normalize('NFKC', 'admin\' ﹣﹣'))
admin' --

模板注入

1
2
3
4
In [39]: print(unicodedata.normalize('NFKC', '﹛﹛3+3﹜﹜'))
{{3+3}}
In [40]: print(unicodedata.normalize('NFKC', '[[5+5]]'))
[[5+5]]

命令注入

1
2
3
4
In [41]: print(unicodedata.normalize('NFKC', '&& whoami'))
&& whoami
In [42]: print(unicodedata.normalize('NFKC', '|| whoami'))
|| whoami

目录穿越

1
2
3
4
In [28]: print(unicodedata.normalize('NFKC', '‥/‥/‥/‥/etc/passwd'))
../../../../etc/passwd
In [29]: print(unicodedata.normalize('NFKC', '︰/︰/︰/︰/etc/passwd'))
../../../../etc/passwd

ssrf

1
2
In [33]: print(unicodedata.normalize('NFKC', '①②⑦.⓪.⓪.①'))
127.0.0.1

绕过文件后缀限制

1
2
In [34]: print(unicodedata.normalize('NFKC', 'test.pʰp'))
test.php

url 跳转

1
2
3
4
5
6
7
8
In [38]: 'tr0y。wang'.encode('idna')
Out[38]: b'tr0y.wang'

In [39]: print(unicodedata.normalize('NFKC', 'https://evil.c℀.baidu.com'))
https://evil.ca/c.baidu.com

In [40]: 'https://evil.c℀.baidu.com'.encode('idna')
Out[40]: b'https://evil.ca/c.baidu.com'

越权

1
2
In [46]: print(unicodedata.normalize('NFKC', 'uname=ªdmin'))
uname=admin

当然,有些 payload 不一定通过浏览器触发(例如最开始的那个 xss),这就要求后端进行了 unicode 规范化,如果后端没有规范化输入,则这些 payload 都是无效的。

总之,在数据流传的节点中,一定要有一个节点进行了 unicode 规范化,这种攻击方式才有可能生效。

至于这些字符是怎么找的,推荐一个网站:https://www.compart.com/en/unicode/ 。可以搜索特定字符的相似字符,例如 T

1
2
In [51]: print(unicodedata.normalize('NFKC', 'ᵀ') == 'T')
True

最后再推荐一个工具,可以用来 fuzz 这种特殊字符:

https://github.com/h13t0ry/UnicodeToy

有趣的是,(2022.05.23)我发现了这个 repo,准备在本文中引用它的时候,发现它的 repo 里引了本文,太巧了哈哈。

一些想法

  1. Unicode,永远滴神!
  2. RFC 文档其实对各种细节说的很清楚,但是它就和官方文档一样,一般没人愿意好好看...这两天看了好几份 RFC 文档,人都快傻了...
  3. 深挖技术细节其实很费时间与精力,是场孤独的修行。

来呀快活呀


从一个绕过长度限制的 XSS 中,我们能学到什么?
https://www.tr0y.wang/2020/08/18/IDN/
作者
Tr0y
发布于
2020年8月18日
更新于
2024年4月19日
许可协议