从CTF题看Unicode等价性漏洞:字符编码安全深度解析

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'}

逻辑非常清晰:

  1. 获取商品ID和用户输入的字符 char
  2. 检查 char 长度是否为1( len(char) > 1 )。
  3. 使用Python标准库 unicodedata.numeric() 函数,尝试将 char 解释为一个数字。如果 char 不是一个能表示数字的Unicode字符(比如字母‘a’),函数返回 None ,报错“Invalid char!”。
  4. 将转换得到的 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为了兼容性和表达丰富性,允许同一个视觉上的字符有多种编码方式。例如,字母“é”可以有两种表示:

  1. 单一码点 :U+00E9 (LATIN SMALL LETTER E WITH ACUTE),这是一个预组合字符。
  2. 组合序列 :U+0065 (LATIN SMALL LETTER E) + U+0301 (COMBINING ACUTE ACCENT),这是一个基础字符加上一个组合附加符号。

虽然编码不同,但它们表示的是同一个字符“é”。这就是 规范等价(Canonically Equivalent) 。Unico

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值