Appearance
魔法动物旋转木马:Ethernaut上的数字奇幻与位操作之舞
欢迎来到一个充满魔法的数字乐园,这里有一座神秘的旋转木马——Magic Animal Carousel。它不仅仅是一个游乐设施,更是一个精心设计的以太坊智能合约挑战,由 c-arlitox477 和 Gianfranco 精心打造。在这个无限循环的数字轮盘上,动物们旋转、跳跃,一切看似井然有序,但其中却暗藏玄机,等待着聪明的匿名者(Anon)来打破它的“魔法规则”。
挑战概览:数字世界的魔法规则
Ethernaut 挑战 98 关卡 "Magic Animal Carousel" 提出了一个看似简单的任务:
- 添加动物入场: 你可以随时将新的动物加入旋转木马。
- 魔法验证: 但请记住,如果你添加了一只动物,下次检查时,这只动物必须还在它原来的位置!
- 打破规则: 你的目标是打破这个看似坚不可摧的“魔法规则”。
简单来说,就是通过与智能合约的交互,使其内部状态进入一个意想不到的、与设计之初不符的境地,从而“破坏魔法”。这其中涉及到 Solidity 语言中一些巧妙的位操作和存储机制。
深入魔法核心:合约剖析 MagicAnimalCarousel.sol
这份智能合约的核心是一个 carousel 的 mapping,它将 crateId (木马箱子的ID)映射到一个 uint256 类型的 animalInside。这个 uint256 可不简单,它就像一个多功能编码器,巧妙地将三种信息“打包”在一起:
OWNER_MASK(低 160 位): 存储箱子的主人地址。NEXT_ID_MASK(位 160-175): 存储下一个木马箱子的 ID。这个 ID 是uint16类型,所以占据 16 位。ANIMAL_MASK(位 176-255): 存储动物的名字。这个名字被编码后存储在最高的 80 位。
合约定义了 MAX_CAPACITY 为 type(uint16).max,即 65535。这暗示着 crateId 可能会在 uint16 的范围内循环。
两个主要功能函数是破解的关键:
setAnimalAndSpin(string calldata animal):- 这个函数负责将新的动物添加到当前的
nextCrateId对应的箱子里。 - 它首先对动物名字进行编码 (
encodeAnimalName),然后将其右移 16 位 (>> 16),再左移160 + 16位(即176位)存入ANIMAL_MASK区域。 - 同时,它更新了
NEXT_ID_MASK和OWNER_MASK,并将currentCrateId更新为新的nextCrateId。
- 这个函数负责将新的动物添加到当前的
changeAnimal(string calldata animal, uint256 crateId):- 这个函数允许所有者更改指定
crateId箱子里的动物。 - 它对动物名字进行编码 (
encodeAnimalName),然后直接将其左移160位 (<< 160) 存入carousel[crateId]。 - 注意!这里是关键所在!
- 这个函数允许所有者更改指定
揭示真相:位移操作的致命错位
核心漏洞就隐藏在 setAnimalAndSpin 和 changeAnimal 两个函数对动物名字的存储方式差异中。
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)。
初始化第一个箱子: 首先,调用
target.setAnimalAndSpin("wtls")。这会创建一个crateId为1的箱子,其nextCrateId默认会设置为2。此时currentCrateId变为1。注入恶意动物,篡改
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。
- 通过低级调用
触发魔术,抵达终点: 再次调用
target.setAnimalAndSpin("anyw")。- 此时
currentCrateId仍然是1。 - 合约会读取
carousel[1]中的nextCrateId。由于我们之前的篡改,现在读取到的是65535。 - 合约将
currentCrateId更新为这个新的nextCrateId,即65535。 - 至此,
currentCrateId成功被设置为type(uint16).max,我们打破了旋转木马的魔法规则!
- 此时
经验教训:数据打包与位操作的艺术
这个挑战生动地展示了在 Solidity 中进行数据打包(data packing)时可能遇到的陷阱:
- 位操作的精确性: 即使是微小的位移差异 (
<< 160 + 16vs<< 160) 也可能导致严重的存储冲突和数据损坏。 - 不同函数的行为一致性: 当多个函数操作相同的数据结构时,必须确保它们对数据布局和处理方式保持高度一致,尤其是在涉及位操作时。
- 参数校验的完整性: 尽管
encodeAnimalName有长度校验,但不同函数中对编码结果的后续处理(如额外的位移)才是导致漏洞的根本原因。 - 低级调用 (Low-Level Calls):
Address.functionCall等低级调用允许我们绕过 Solidity 的高级抽象,直接构造calldata。虽然本例中主要利用了存储逻辑错误,但了解低级调用的灵活性在 CTF 中至关重要。
Magic Animal Carousel 挑战不仅仅是一场数字谜题,更是一堂深刻的智能合约安全课。它提醒着开发者们,在追求效率和存储优化的同时,必须对每一个位操作保持警惕和精确,因为一个微小的错位,就足以让“魔法”失控。