Appearance
白嫖商店: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() 函数的逻辑似乎无懈可击:
- 它要求
msg.sender必须实现IBuyer接口,意味着msg.sender必须是一个合约,并且拥有一个price()视图函数。 - 购买条件是
_buyer.price()(买家告诉商店的“价格”)必须 大于等于Shop当前的price。 - 如果条件满足,
isSold被设为true,商品售出。 - 最关键的一步:
Shop的price会被更新为_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尚未售出 (isSold为false),它返回100。 - 如果
Shop已经售出 (isSold为true),它返回0。
致命的“双重打击”:深入理解交易原子性
现在,让我们一步步揭示这个巧妙的攻击过程:
- 准备阶段: 我们部署
Hack合约,并将Shop合约的地址作为参数传递给它。 - 发起攻击: 我们调用
Hack合约的doBuy()函数。 - 进入
Shop.buy():Hack.doBuy()会进而调用Shop合约的buy()函数。此时,msg.sender是我们的Hack合约。 - 第一次
_buyer.price()调用 (条件判断):Shop合约通过IBuyer(msg.sender)(也就是Hack合约)调用其price()函数。Hack.price()被执行。在Hack.price()内部,它会查询target.isSold(),也就是Shop合约的isSold状态。- 重点来了! 在当前这个交易的执行流中,
Shop.isSold变量在buy()函数的开头还是false。 - 所以,
target.isSold()返回false,Hack.price()因此返回100。 Shop合约的if条件(100 >= 100 && !false)评估为true。条件满足,交易继续。
- 状态更新:
Shop合约执行isSold = true;。现在,在当前交易的“中间状态”中,Shop合约的isSold已经变成了true。 - 第二次
_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 合约的状态:isSold 为 true,而 price 竟然变成了 0!我们成功地以“零元”的价格买到了商品。
总结与启示:视图函数并非“纯洁无瑕”
这个 Ethernaut “Shop”挑战完美地展示了一个重要的智能合约安全概念:尽管视图函数 (view functions) 不应该修改状态,但当它们在同一个交易中被多次调用时,它们可以观察到该交易中此前发生的状态改变。
这种行为利用了 EVM 交易的原子性,即一个交易中的所有操作要么全部成功,要么全部失败。在一个交易内部,状态的改变是即时可见的,即使对于被调用的 view 函数也是如此。
本次挑战带给我们的启示:
msg.sender的力量: 永远不要低估msg.sender是一个合约而不是 EOA (外部账户) 时可能带来的复杂性。外部合约可以实现接口,并以意想不到的方式影响你的逻辑。- 外部调用的风险: 在智能合约中进行外部调用(尤其是对用户提供的地址进行调用)时必须极其谨慎。这些外部调用可能会重入你的合约,或者像本例一样,根据你合约内部的中间状态返回不同的结果。
- 对
view函数的重新审视: 尽管view函数不能修改链上状态,但它们可以读取当前交易的中间状态。如果你的逻辑依赖于在同一个交易中多次调用view函数,并假设它们总是返回相同的结果,那么你可能就掉入了陷阱。
“Shop”挑战是一个极佳的案例,它以巧妙的方式提醒我们,智能合约的安全审计需要深入理解 Solidity 语言特性、EVM 工作原理以及交易的原子性。下次你编写或审计合约时,不妨回想起这个“零元购”的商店,也许能帮你避免类似的漏洞!