Appearance
ETHerNaut 寻宝之旅:Stake 合约的秘密与妙计
欢迎来到 ETHerNaut 的奇幻冒险!今天,我们将深入一个名为 Stake 的智能合约,探索它隐藏的漏洞,并学习如何巧妙地“榨干”它。准备好你的以太坊技能,让我们开始这场智慧与代码的较量!
探秘 Stake 合约:它承诺了什么?
Stake 合约的初衷是为用户提供一个安全的平台,让他们可以质押(stake)原生的 ETH 和 WETH(ERC20 包装的 ETH)。它的核心理念是 1:1 的价值兑换,也就是说,你质押 1 ETH,理论上就能获得 1 ETH 的价值回报。听起来很不错,对吧?
然而,在 CTF 的世界里,每一个看似美好的承诺都可能隐藏着一个待被揭示的陷阱。Stake 合约的作者 Gustavo Waiandt 和 Afonso Dalvi 留给我们的挑战是:你能否“榨干”这个合约,让它空虚无物?
挑战目标:打破平静的合约状态
为了成功完成这个挑战,我们需要让 Stake 合约达到一个非常特殊的“不平衡”状态。具体来说,以下四个条件必须同时满足:
- 合约的 ETH 余额必须大于 0: 这意味着 Stake 合约本身必须持有一定数量的 ETH。
totalStaked必须大于 Stake 合约的 ETH 余额: 这是关键!合约记录的总质押量,要比它实际持有的 ETH 还要多。这暗示了一种潜在的“超额承诺”或“账目不符”。- 你必须是一个“Staker”: 你必须曾经通过合约进行过质押操作,成为合约的用户之一。
- 你的质押余额必须为 0: 尽管你曾是 Staker,但现在你从合约中取回了所有的质押物,你的个人质押记录显示为零。
破局之道:巧妙利用 ERC-20 与 OpenZeppelin
为了应对这个挑战,以下工具可能会对你有所帮助:
- ERC-20 规范: 了解 ERC-20 代币的标准接口和交互方式至关重要,尤其是
approve和transferFrom函数,它们是 WETH 交互的关键。 - OpenZeppelin Contracts: 这个流行的 Solidity 库提供了许多经过审计和标准化的智能合约组件,包括 ERC-20 代币的标准实现。虽然在这个题目中我们主要关注 Stake 合约自身,但理解 ERC-20 的底层逻辑离不开它。
策略剖析:如何实现“榨干”?
通过仔细分析 Stake.sol 合约的代码,我们可以发现一些线索:
StakeETH(): 这个函数允许用户直接质押 ETH,并更新totalStaked和UserStake。StakeWETH(uint256 amount): 这个函数则负责质押 WETH。它首先检查你的allowance(授权额度),然后调用WETH.call进行转账。Unstake(uint256 amount): 这个函数允许用户取回质押的资产。它会减少UserStake和totalStaked,并尝试将资产发送回用户。
解决方案揭秘:Hack.sol 的妙计
正如您在提供的 Hack.sol 中看到的,解决方案的精髓在于利用合约的薄弱之处,制造 totalStaked 远大于合约 ETH 余额的情况,同时确保满足其他条件。
Hack.sol 合约的思路是:
- 部署
Hack合约: 构造函数接收 Stake 合约的地址,并将其存储在target变量中。 - 调用
byContract(): 这个函数会直接调用 Stake 合约的StakeETH()方法,并且使用msg.value来发送 ETH。 - 关键点: 题目描述和测试用例(
98_test_stake.ts)告诉我们,StakeETH函数会增加totalStaked。而在测试用例中,我们看到先是通过hack.byContract发送了非常微小的 ETH,这会增加totalStaked。随后,通过stkContract.StakeWETH质押了一个较大的 WETH 金额,这也会增加totalStaked。
漏洞分析与攻击流程
测试用例 98_test_stake.ts 揭示了攻击的完整流程:
部署
Hack合约,并调用hack.byContract({ value: STAKE_ETH }):- 这里发送了一个非常小的 ETH (
0.001000000000000002 ether)。 StakeETH被调用,msg.value(0.001000000000000002 ether) 被加入到totalStaked。- 此时,Stake 合约的 ETH 余额增加了这个值,
totalStaked也增加了这个值。
- 这里发送了一个非常小的 ETH (
对 WETH 代币进行
approve:wethContract.approve(STAKE_ADDRESS, APPROVE_UINT256_MAX)授权 Stake 合约可以花费任意数量的 WETH。
调用
stkContract.StakeWETH(INIT_ETH):- 这里质押了一个较大的 WETH 金额 (
0.001000000000000001 ether,注意这个值比第一个 ETH 质押的值略小,但我们只是需要它大于合约的 ETH 余额来满足条件,并且我们有无限的 WETH 可以授权和质押)。 StakeWETH调用成功,amount(0.001000000000000001 ether) 被加入到totalStaked。UserStake[deployer.address]增加。Stakers[deployer.address]变为true。
- 这里质押了一个较大的 WETH 金额 (
调用
stkContract.Unstake(INIT_ETH):- 用户(deployer)取回之前质押的 WETH。
UserStake[deployer.address]减去INIT_ETH,最终变为 0。totalStaked减去INIT_ETH。- 然而,
Unstake函数只是简单地将 ETH 发送到payable(msg.sender).call{value : amount}(""),但它没有处理 WETH 的退回逻辑! 这意味着,虽然totalStaked和UserStake都减少了,但合约并没有真正地将 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 寻宝之旅,能让你对智能合约的安全攻防有更深刻的理解!下次再见!