1. 项目概述:从一道CTF题看Unicode的“等价性”陷阱
最近在复盘一些经典的Web安全挑战时,我又把BUUCTF平台上那道“[ASIS 2019] Unicorn Shop”翻出来玩了一遍。这道题在CTF圈子里挺有名,它不像常规的SQL注入或XSS那样直接,而是巧妙地利用了Unicode编码中一个非常隐蔽的特性——等价性(Equivalence)。很多朋友第一次做这道题时,即使看到了源码,也可能卡在最后一步,不明白为什么一个字符能买走价值1337个金币的独角兽。今天,我就结合这道题,把Unicode编码安全这个相对冷门但极其重要的知识点掰开揉碎了讲清楚。无论你是正在打CTF的选手,还是对Web安全底层原理感兴趣的开发者,理解这个漏洞都能帮你建立起对字符编码更深刻的安全意识。
简单来说,这道题模拟了一个在线商店,你可以用金币购买不同价格的独角兽。最贵的那只“独角兽之泪”标价1337金币。题目限制你只能输入“一个字符”作为支付凭证。我们的目标就是找到那个“特殊”的字符,让它被系统识别为价值1337的支付凭证,从而成功购买。这背后的核心,就是Unicode的“兼容字符”和“正规化”机制在作祟。很多编程语言和数据库在处理字符串时,如果没做好规范化(Normalization),就可能让看似不同的字符被判定为“等价”,从而引发逻辑漏洞。接下来,我会带你一步步拆解题目环境、分析源码、理解漏洞原理,并最终给出完整的利用过程。我们不仅解题,更要弄懂为什么能这样解。
2. 环境搭建与题目初探
2.1 题目场景还原
首先,我们得知道题目长什么样。我在本地用Docker快速搭建了一个复现环境,代码逻辑和原题一致。访问页面,你会看到一个非常简洁的商店界面,列出了四只独角兽,价格分别是:
- 🦄 普通独角兽 - 10金币
- 🦄 彩虹独角兽 - 25金币
- 🦄 暗影独角兽 - 100金币
- 🦄 独角兽之泪 - 1337金币
每个商品下方有一个输入框和一个“Purchase”按钮。页面的提示很关键:“You can only buy one character with one coin!”(你只能用一个金币购买一个字符)。这直接指明了第一个限制条件:支付凭证(或者说我们提交的“字符”)长度必须为1。
尝试购买最便宜的10金币独角兽,如果你在输入框里直接输入数字“10”,系统会提示“Invalid char!”。这说明它对我们输入的内容做了严格的检查,只允许特定的“一个字符”通过。那么,什么样的字符能代表数字10呢?我们很自然地会想到罗马数字,比如“Ⅹ”就是10。尝试输入大写罗马数字“Ⅹ”(Unicode码点U+2169),果然成功买到了普通独角兽!同理,25对应“ⅩⅩⅤ”,100对应“Ⅽ”,但注意,它们都不是“一个字符”。题目要求一个字符,而1337用罗马数字表示非常长,这显然不是正道。突破口一定在“一个字符代表1337”这个看似不可能的任务上。
2.2 源码逻辑深度剖析
要找到突破口,必须看后端如何处理我们的输入。题目的Python源码(Flask框架)是关键。核心的购买逻辑大概如下:
@app.route('/shop', methods=['POST'])
def shop():
try:
product_id = request.form.get('product_id')
unicorn_name = unicorns.get(int(product_id))
if not unicorn_name:
return 'Invalid product ID!', 400
price = prices.get(int(product_id))
if price is None:
return 'Invalid product ID!', 400
# 关键检查点1:用户输入的字符
char = request.form.get('char')
if not char:
return 'Empty char!', 400
# 关键检查点2:长度必须为1
if len(char) > 1:
return 'You can only buy one character with one coin!', 400
# 关键检查点3:将字符转换为数字
# 这里使用了 unicodedata.numeric() 函数
number = unicodedata.numeric(char, None)
if number is None:
return 'Invalid char!', 400
# 关键检查点4:转换后的数字必须大于等于商品价格
if number < price:
return 'You can\'t afford this unicorn!', 400
# 购买成功
return f'You bought a {unicorn_name} for {number} coins!'
except Exception as e:
return str(e), 500
以及定义价格和商品的字典:
prices = {1: 10, 2: 25, 3: 100, 4: 1337}
unicorns = {1: 'Normal Unicorn', 2: 'Rainbow Unicorn', 3: 'Shadow Unicorn', 4: 'Unicorn Tears'}
逻辑非常清晰:
- 获取商品ID和用户输入的字符
char。 - 检查
char长度是否为1(len(char) > 1)。 - 使用Python标准库
unicodedata.numeric()函数,尝试将char解释为一个数字。如果char不是一个能表示数字的Unicode字符(比如字母‘a’),函数返回None,报错“Invalid char!”。 - 将转换得到的
number与商品价格price比较,如果number >= price,则购买成功。
所以,我们的任务简化为: 找到一个Unicode字符,它满足:1)字符串长度为1;2)能被 unicodedata.numeric() 成功解析;3)解析出的数值大于等于1337。
注意 :这里有一个至关重要的细节,也是漏洞的根源之一:
len(char)检查的是字符串的 长度(Length) ,而不是字符的 宽度(Width) 或 码点数量 。在Python中,一个包含组合字符(如基础字符+附加符号)的字符串,其len()可能大于1,但它在视觉上可能仍然显示为一个“字符”。我们稍后会利用这一点。
3. Unicode编码漏洞原理深度解析
3.1 Unicode正规化与等价性
要理解漏洞,必须先搞懂两个核心概念: 正规化(Normalization) 和 字符等价性(Character Equivalence) 。
Unicode为了兼容性和表达丰富性,允许同一个视觉上的字符有多种编码方式。例如,字母“é”可以有两种表示:
- 单一码点 :U+00E9 (LATIN SMALL LETTER E WITH ACUTE),这是一个预组合字符。
- 组合序列 :U+0065 (LATIN SMALL LETTER E) + U+0301 (COMBINING ACUTE ACCENT),这是一个基础字符加上一个组合附加符号。
虽然编码不同,但它们表示的是同一个字符“é”。这就是 规范等价(Canonically Equivalent) 。Unico



被折叠的 条评论
为什么被折叠?



