Appearance
智取投注屋:Ethernaut CTF 挑战的精妙旁敲侧击
欢迎来到 Ethernaut 的世界,一个专为探索以太坊智能合约安全漏洞而设计的迷人游乐场。今天,我们将深入探讨一个名为“Bet House”(投注屋)的挑战。这个关卡由 Claudia Barcelo 精心设计,旨在考验你对智能合约逻辑和潜在陷阱的理解。
挑战网址:https://ethernaut.openzeppelin.com/level/0x0891DF8A34990fE1d149318a65959d1D1ee25A4d
挑战背景:欢迎来到投注屋
你带着 5 个池子存款代币(Pool Deposit Tokens, 简称 PDT)进入了 Bet House。你的目标是“掌握策略性赌博的艺术,成为一名真正的投注者”。听起来很酷,对吗?但要成为投注者,你需要满足一些条件。
我们先来看看两个核心合约:BetHouse.sol 和 Pool.sol。
BetHouse.sol:成为投注者的门槛
BetHouse 合约相当简洁。它的主要功能是 makeBet。要成功调用 makeBet(address bettor_),你需要满足两个关键条件:
- 足够的资金:
Pool(pool).balanceOf(msg.sender)必须大于或等于BET_PRICE。BET_PRICE被设定为 20。这意味着你需要在Pool合约中持有至少 20 个“包裹代币”(wrappedToken)。 - 存款已锁定:
Pool(pool).depositsLocked(msg.sender)必须返回true。这意味着你的存款必须在Pool合约中被“锁定”。
满足这两个条件后,你的地址将被标记为 bettors[bettor_] = true,你就成功晋级为投注者了!
问题来了,我们只有 5 个 PDT,如何才能获得 20 个包裹代币,并锁定存款呢?这就要看 Pool.sol 了。
Pool.sol:资金池的规则
Pool 合约是资金流转的核心,它定义了如何存入资金、获取包裹代币以及如何锁定存款。
存款函数 deposit(uint256 value_):
这是最关键的函数。它允许你通过两种方式获得包裹代币:
- 存入 ETH: 如果你发送
0.001 ether,你将获得 10 个包裹代币。- 关键点一: 这个操作由一个名为
alreadyDeposited的布尔值控制。一旦有人成功存入0.001 ether,alreadyDeposited就会被设为true。更重要的是,这个alreadyDeposited是全局的!这意味着一旦有任何账户存入了0.001 ether,其他账户(包括你自己的其他交易)就不能再通过存入 ETH 的方式获得包裹代币了。这个限制非常重要。
- 关键点一: 这个操作由一个名为
- 存入 PDT: 如果你存入
value_数量的 PDT,你将获得value_数量的包裹代币。
其他重要函数:
withdrawAll():允许你提取所有已存入的 PDT 和 ETH,同时销毁你持有的所有包裹代币。它还带有nonReentrant保护,防止重入攻击。lockDeposits():将depositsLockedMap[msg.sender]设置为true,从而满足BetHouse合约的第二个条件。
挑战分析:寻找漏洞或巧妙之处
我们一开始只有 5 个 PDT。目标是 20 个包裹代币。 如果只存 PDT,我们需要 20 个 PDT。 如果存 0.001 ETH,我们可以得到 10 个包裹代币,还需要 10 个包裹代币。这可以通过再存 10 个 PDT 实现,但我们只有 5 个。
那么,如何利用 Pool 合约的规则呢?那个全局的 alreadyDeposited 标志引起了我的注意。一旦设置为 true,我们就不能再次通过存入 ETH 获得包裹代币。但这并不意味着我们不能再次调用 deposit 函数。
解决方案:MyContract.sol 的精妙设计
解决这个挑战需要一个中介合约 MyContract.sol 来精心编排一系列操作。
MyContract部署和初始操作:- 我们部署
MyContract,并确保它获得了玩家的 5 个 PDT 的授权(_depositToken.approve(address(my), type(uint256).max))。 - 玩家向
MyContract发送 0.001 ETH,并调用my.play()。
- 我们部署
第一次存款与状态改变:
- 在
my.play()中,MyContract首先将玩家的 5 PDT 转移到自己名下,然后将 0.001 ETH 和 5 PDT 一同存入Pool合约:_pool.deposit{value: ETH_AMOUNT}(PDT_AMOUNT)。 - 此时,
MyContract获得了 10(来自 ETH)+ 5(来自 PDT)= 15 个包裹代币。 - 最重要的是,
Pool合约中的alreadyDeposited标志现在被设为true。
- 在
巧妙的“重入”与二次存款:
- 紧接着,
MyContract调用_pool.withdrawAll()。这将把MyContract之前存入的 0.001 ETH 和 5 PDT 返还给它,并销毁它持有的 15 个包裹代币。 - 当 0.001 ETH 返还到
MyContract时,它会触发MyContract的receive()函数。 - 在
receive()函数内部,MyContract再次调用_pool.deposit(PDT_AMOUNT),但这次不发送 ETH。 - 由于
Pool合约的alreadyDeposited已经是true,deposit函数中处理 ETH 的那部分代码会被跳过,因为它会检查if (alreadyDeposited) revert AlreadyDeposited();。 - 然而,处理 PDT 的部分
if (value_ > 0)仍然会执行! - 因此,
MyContract成功地再次存入了它的 5 个 PDT,并获得了额外的 5 个包裹代币。
- 紧接着,
积累足够的包裹代币:
- 第一次存款获得了 15 个包裹代币。虽然它们在
withdrawAll()时被销毁了,但第二次存款又获得了 5 个包裹代币。这似乎并没有达到 20 个。 - 关键点: 第一次
deposit给了MyContract15 个包裹代币。withdrawAll发生在MyContract拥有这些代币之后。在withdrawAll返回 ETH 到MyContract时,MyContract的receive函数触发。此时,MyContract已经拥有了 15 个包裹代币,并且在receive中又存入了 5 PDT,获得了额外的 5 个包裹代币。所以,总共是15 + 5 = 20个包裹代币! - 最终,
MyContract将这 20 个包裹代币全部转移给原始玩家 (_player)。
- 第一次存款获得了 15 个包裹代币。虽然它们在
完成挑战:
- 玩家现在拥有 20 个包裹代币。
- 调用
Pool(POOL_INST).lockDeposits()锁定存款。 - 最后,调用
BetHouse(BETHOUSE_INST).makeBet(player)。
至此,玩家成功地满足了所有条件,成为了 Bet House 的一名投注者!
总结与启示
“Bet House”挑战是一个经典的智能合约漏洞利用示例,它并非依赖于复杂的数学或加密学,而是利用了合约状态管理中的一个微妙缺陷:全局状态变量(alreadyDeposited)的不当使用。
通过一个精心设计的中间合约,我们能够:
- 在第一次存款中触发全局状态的改变。
- 利用
withdrawAll的副作用(ETH 返回导致receive触发)进行第二次存款。 - 绕过第一次存款设置的全局限制(不能再次存入 ETH),但仍然能够存入 PDT,从而积累所需的包裹代币。
这个挑战提醒我们,在设计智能合约时,对全局状态变量、函数副作用和跨合约交互的理解至关重要。一个看似无害的布尔标志,如果其作用域没有被仔细考虑,就可能成为被攻击者利用的“后门”。在区块链的世界里,“掌握策略性赌博的艺术”有时真的需要对代码的每个细节都了如指掌!