Skip to content
On this page

魔法动物旋转木马:Ethernaut上的数字奇幻与位操作之舞

欢迎来到一个充满魔法的数字乐园,这里有一座神秘的旋转木马——Magic Animal Carousel。它不仅仅是一个游乐设施,更是一个精心设计的以太坊智能合约挑战,由 c-arlitox477 和 Gianfranco 精心打造。在这个无限循环的数字轮盘上,动物们旋转、跳跃,一切看似井然有序,但其中却暗藏玄机,等待着聪明的匿名者(Anon)来打破它的“魔法规则”。

挑战概览:数字世界的魔法规则

Ethernaut 挑战 98 关卡 "Magic Animal Carousel" 提出了一个看似简单的任务:

  1. 添加动物入场: 你可以随时将新的动物加入旋转木马。
  2. 魔法验证: 但请记住,如果你添加了一只动物,下次检查时,这只动物必须还在它原来的位置!
  3. 打破规则: 你的目标是打破这个看似坚不可摧的“魔法规则”。

简单来说,就是通过与智能合约的交互,使其内部状态进入一个意想不到的、与设计之初不符的境地,从而“破坏魔法”。这其中涉及到 Solidity 语言中一些巧妙的位操作和存储机制。

深入魔法核心:合约剖析 MagicAnimalCarousel.sol

这份智能合约的核心是一个 carouselmapping,它将 crateId (木马箱子的ID)映射到一个 uint256 类型的 animalInside。这个 uint256 可不简单,它就像一个多功能编码器,巧妙地将三种信息“打包”在一起:

  • OWNER_MASK (低 160 位): 存储箱子的主人地址。
  • NEXT_ID_MASK (位 160-175): 存储下一个木马箱子的 ID。这个 ID 是 uint16 类型,所以占据 16 位。
  • ANIMAL_MASK (位 176-255): 存储动物的名字。这个名字被编码后存储在最高的 80 位。

合约定义了 MAX_CAPACITYtype(uint16).max,即 65535。这暗示着 crateId 可能会在 uint16 的范围内循环。

两个主要功能函数是破解的关键:

  1. setAnimalAndSpin(string calldata animal):

    • 这个函数负责将新的动物添加到当前的 nextCrateId 对应的箱子里。
    • 它首先对动物名字进行编码 (encodeAnimalName),然后将其右移 16 位 (>> 16),再左移 160 + 16(即 176 位)存入 ANIMAL_MASK 区域。
    • 同时,它更新了 NEXT_ID_MASKOWNER_MASK,并将 currentCrateId 更新为新的 nextCrateId
  2. changeAnimal(string calldata animal, uint256 crateId):

    • 这个函数允许所有者更改指定 crateId 箱子里的动物。
    • 它对动物名字进行编码 (encodeAnimalName),然后直接将其左移 160 (<< 160) 存入 carousel[crateId]
    • 注意!这里是关键所在!

揭示真相:位移操作的致命错位

核心漏洞就隐藏在 setAnimalAndSpinchangeAnimal 两个函数对动物名字的存储方式差异中。

  • encodeAnimalName 函数能够将最长 12 字节的动物名字编码成一个 uint256,它实际上占据了 uint256 的低 96 位 (12 * 8 = 96)。
  • setAnimalAndSpin
    • 它会先 encodeAnimalName(animal) >> 16,这会将编码后的 96 位名字截断到 80 位,并放在 uint256 的低 80 位。
    • 然后将其 << 176,使其占据 176-255 位 (ANIMAL_MASK 区域)。
    • 这意味着 setAnimalAndSpin 只能正确存储最多 10 字节(80 位)的动物名字,并且其存储起始点是位 176
  • changeAnimal
    • 它直接调用 encodeAnimalName(animal),获取到最长 12 字节(96 位)的编码。
    • 然后将其 << 160,使其占据 160-255 位。

看清了吗?致命的错位就在这里!

setAnimalAndSpin 认为动物名字从位 176 开始,而 changeAnimal 却可以从位 160 开始写入!当 changeAnimal 函数被调用,并且传入一个长度为 12 字节(96 位)的动物名字时,它会从位 160 开始写入这 96 位数据:

  • 最高的 80 位 (176-255) 会覆盖 ANIMAL_MASK 区域。
  • 最关键的是,最低的 16 位 (160-175) 将会溢出并覆盖 NEXT_ID_MASK 区域!

这就是我们打破魔法规则的突破口!我们可以通过 changeAnimal 函数写入一个精心构造的 12 字节动物名字,从而篡改某个箱子的 nextCrateId

魔法破解:通往 MAX_CAPACITY 的旅程

我们的目标是让 currentCrateId 最终变为 type(uint16).max (即 65535)。

  1. 初始化第一个箱子: 首先,调用 target.setAnimalAndSpin("wtls")。这会创建一个 crateId1 的箱子,其 nextCrateId 默认会设置为 2。此时 currentCrateId 变为 1

  2. 注入恶意动物,篡改 nextCrateId 现在,我们利用 changeAnimal 的位移漏洞。我们需要构造一个 12 字节的动物名字,其前两个字节(对应覆盖 NEXT_ID_MASK 的 16 位)是 0xFFFF,即 65535。例如,hex"ffffdeadbeefcafebabe" 就是一个符合要求的 12 字节数据。

    • 通过低级调用 Address.functionCall,精确地调用 changeAnimal(string_containing_0xFFFF_prefix, 1)
    • 这个调用会修改 carousel[1] 的存储。由于 encodedAnimal 被左移 160 位,它的前两个字节 0xFFFF 会精确地覆盖 carousel[1] 中的 NEXT_ID_MASK 区域,使其 nextCrateId 变为 65535
  3. 触发魔术,抵达终点: 再次调用 target.setAnimalAndSpin("anyw")

    • 此时 currentCrateId 仍然是 1
    • 合约会读取 carousel[1] 中的 nextCrateId。由于我们之前的篡改,现在读取到的是 65535
    • 合约将 currentCrateId 更新为这个新的 nextCrateId,即 65535
    • 至此,currentCrateId 成功被设置为 type(uint16).max,我们打破了旋转木马的魔法规则!

经验教训:数据打包与位操作的艺术

这个挑战生动地展示了在 Solidity 中进行数据打包(data packing)时可能遇到的陷阱:

  • 位操作的精确性: 即使是微小的位移差异 (<< 160 + 16 vs << 160) 也可能导致严重的存储冲突和数据损坏。
  • 不同函数的行为一致性: 当多个函数操作相同的数据结构时,必须确保它们对数据布局和处理方式保持高度一致,尤其是在涉及位操作时。
  • 参数校验的完整性: 尽管 encodeAnimalName 有长度校验,但不同函数中对编码结果的后续处理(如额外的位移)才是导致漏洞的根本原因。
  • 低级调用 (Low-Level Calls): Address.functionCall 等低级调用允许我们绕过 Solidity 的高级抽象,直接构造 calldata。虽然本例中主要利用了存储逻辑错误,但了解低级调用的灵活性在 CTF 中至关重要。

Magic Animal Carousel 挑战不仅仅是一场数字谜题,更是一堂深刻的智能合约安全课。它提醒着开发者们,在追求效率和存储优化的同时,必须对每一个位操作保持警惕和精确,因为一个微小的错位,就足以让“魔法”失控。


Built with AiAda