Skip to content
On this page

ETHerNaut 寻宝之旅:Stake 合约的秘密与妙计

欢迎来到 ETHerNaut 的奇幻冒险!今天,我们将深入一个名为 Stake 的智能合约,探索它隐藏的漏洞,并学习如何巧妙地“榨干”它。准备好你的以太坊技能,让我们开始这场智慧与代码的较量!

探秘 Stake 合约:它承诺了什么?

Stake 合约的初衷是为用户提供一个安全的平台,让他们可以质押(stake)原生的 ETH 和 WETH(ERC20 包装的 ETH)。它的核心理念是 1:1 的价值兑换,也就是说,你质押 1 ETH,理论上就能获得 1 ETH 的价值回报。听起来很不错,对吧?

然而,在 CTF 的世界里,每一个看似美好的承诺都可能隐藏着一个待被揭示的陷阱。Stake 合约的作者 Gustavo Waiandt 和 Afonso Dalvi 留给我们的挑战是:你能否“榨干”这个合约,让它空虚无物?

挑战目标:打破平静的合约状态

为了成功完成这个挑战,我们需要让 Stake 合约达到一个非常特殊的“不平衡”状态。具体来说,以下四个条件必须同时满足:

  1. 合约的 ETH 余额必须大于 0: 这意味着 Stake 合约本身必须持有一定数量的 ETH。
  2. totalStaked 必须大于 Stake 合约的 ETH 余额: 这是关键!合约记录的总质押量,要比它实际持有的 ETH 还要多。这暗示了一种潜在的“超额承诺”或“账目不符”。
  3. 你必须是一个“Staker”: 你必须曾经通过合约进行过质押操作,成为合约的用户之一。
  4. 你的质押余额必须为 0: 尽管你曾是 Staker,但现在你从合约中取回了所有的质押物,你的个人质押记录显示为零。

破局之道:巧妙利用 ERC-20 与 OpenZeppelin

为了应对这个挑战,以下工具可能会对你有所帮助:

  • ERC-20 规范: 了解 ERC-20 代币的标准接口和交互方式至关重要,尤其是 approvetransferFrom 函数,它们是 WETH 交互的关键。
  • OpenZeppelin Contracts: 这个流行的 Solidity 库提供了许多经过审计和标准化的智能合约组件,包括 ERC-20 代币的标准实现。虽然在这个题目中我们主要关注 Stake 合约自身,但理解 ERC-20 的底层逻辑离不开它。

策略剖析:如何实现“榨干”?

通过仔细分析 Stake.sol 合约的代码,我们可以发现一些线索:

  • StakeETH() 这个函数允许用户直接质押 ETH,并更新 totalStakedUserStake
  • StakeWETH(uint256 amount) 这个函数则负责质押 WETH。它首先检查你的 allowance(授权额度),然后调用 WETH.call 进行转账。
  • Unstake(uint256 amount) 这个函数允许用户取回质押的资产。它会减少 UserStaketotalStaked,并尝试将资产发送回用户。

解决方案揭秘:Hack.sol 的妙计

正如您在提供的 Hack.sol 中看到的,解决方案的精髓在于利用合约的薄弱之处,制造 totalStaked 远大于合约 ETH 余额的情况,同时确保满足其他条件。

Hack.sol 合约的思路是:

  1. 部署 Hack 合约: 构造函数接收 Stake 合约的地址,并将其存储在 target 变量中。
  2. 调用 byContract() 这个函数会直接调用 Stake 合约的 StakeETH() 方法,并且使用 msg.value 来发送 ETH
  3. 关键点: 题目描述和测试用例(98_test_stake.ts)告诉我们,StakeETH 函数会增加 totalStaked。而在测试用例中,我们看到先是通过 hack.byContract 发送了非常微小的 ETH,这会增加 totalStaked。随后,通过 stkContract.StakeWETH 质押了一个较大的 WETH 金额,这也会增加 totalStaked

漏洞分析与攻击流程

测试用例 98_test_stake.ts 揭示了攻击的完整流程:

  1. 部署 Hack 合约,并调用 hack.byContract({ value: STAKE_ETH })

    • 这里发送了一个非常小的 ETH (0.001000000000000002 ether)。
    • StakeETH 被调用,msg.value (0.001000000000000002 ether) 被加入到 totalStaked
    • 此时,Stake 合约的 ETH 余额增加了这个值,totalStaked 也增加了这个值。
  2. 对 WETH 代币进行 approve

    • wethContract.approve(STAKE_ADDRESS, APPROVE_UINT256_MAX) 授权 Stake 合约可以花费任意数量的 WETH。
  3. 调用 stkContract.StakeWETH(INIT_ETH)

    • 这里质押了一个较大的 WETH 金额 (0.001000000000000001 ether,注意这个值比第一个 ETH 质押的值略小,但我们只是需要它大于合约的 ETH 余额来满足条件,并且我们有无限的 WETH 可以授权和质押)。
    • StakeWETH 调用成功,amount (0.001000000000000001 ether) 被加入到 totalStaked
    • UserStake[deployer.address] 增加。
    • Stakers[deployer.address] 变为 true
  4. 调用 stkContract.Unstake(INIT_ETH)

    • 用户(deployer)取回之前质押的 WETH。
    • UserStake[deployer.address] 减去 INIT_ETH,最终变为 0。
    • totalStaked 减去 INIT_ETH
    • 然而,Unstake 函数只是简单地将 ETH 发送到 payable(msg.sender).call{value : amount}(""),但它没有处理 WETH 的退回逻辑! 这意味着,虽然 totalStakedUserStake 都减少了,但合约并没有真正地将 WETH 从合约中移出,只是修改了计数。

最终胜利的条件达成

经过上述操作:

  • Stake 合约 ETH 余额 > 0:StakeETH 时,合约收到了 ETH。
  • totalStaked > Stake 合约 ETH 余额:
    • totalStaked 经历了 + STAKE_ETH + INIT_ETH - INIT_ETH
    • Stake 合约的 ETH 余额是 + STAKE_ETH
    • 理论上,如果 INIT_ETH 足够大,totalStaked 会远大于合约的 ETH 余额(STAKE_ETH)。
  • 你是 Staker: Stakers[deployer.address] 已经设置为 true
  • 你的质押余额为 0: UserStake[deployer.address]Unstake 后被设为 0。

此时,totalStaked 记录了一个更高的数值,但合约实际持有的 ETH 却只增加了 STAKE_ETH。通过精心选择质押和取消质押的 WETH 金额,我们可以让 totalStaked 远远大于合约中的 ETH 余额。

结语

Stake 合约的挑战,不仅仅是代码的较量,更是对逻辑思维的考验。通过理解合约的运作机制,发现其设计上的不足(特别是 WETH 在 Unstake 时未被真正移除),我们就能巧妙地构造出满足所有条件的状态,从而“榨干”合约。

希望这次 ETHerNaut 寻宝之旅,能让你对智能合约的安全攻防有更深刻的理解!下次再见!

Built with AiAda