Skip to content
On this page

智能合约界的“权力的游戏”:一招制敌,谁是真国王?

欢迎来到 Ethernaut 的“King”关卡,一场发生在区块链上的“权力的游戏”正在上演!这次,我们将扮演一个无情的挑战者,目标是打破这个看似简单却暗藏玄机的智能合约游戏。

游戏规则:谁比我强,谁就称王!

让我们先来了解一下这场游戏的规则。你面前的这个智能合约,就像一个迷你王国。

  • 国王的宝座: 合约中有一个 king 地址,记录着当前“国王”是谁。
  • 奖金池: prize 变量存储着当前王国的“奖金池”金额。
  • 游戏机制: 任何发送比当前 prize 更大金额的以太坊(Ether)到这个合约的人,就能立刻成为新的国王!
  • 老国王的谢幕: 当出现新的国王时,被“废黜”的老国王会收到刚刚被新国王贡献的这笔以太坊金额,并能从中获利。
  • “庞氏骗局”般的循环: 这种机制听起来是不是有点像经典的庞氏骗局?不断有人投入资金,前一位受益,新的“国王”诞生,旧的“国王”退出,游戏继续。

你的任务:打破游戏的平衡

你的目标是成为那个打破这个循环的人。然而,这里有一个关键的“反制”机制:

当你成功将这个挑战者合约提交回 Ethernaut 关卡时,关卡本身会“自动”夺回国王的头衔。

这意味着,你不能简单地通过发送更高的金额成为国王,因为最终的胜利者不是你,而是 Ethernaut 关卡。你需要找到一种方法,在 Ethernaut 关卡夺回王权之前,就已经成功“获胜”

深入剖析合约代码:寻找漏洞

让我们来看看 King.sol 这个合约,看看它隐藏着什么玄机:

solidity
contract King {
    address king;
    uint256 public prize;
    address public owner;

    constructor() payable {
        owner = msg.sender; // 合约部署者是 owner
        king = msg.sender;  // 部署者也是初始国王
        prize = msg.value;  // 初始奖金池等于部署时发送的金额
    }

    receive() external payable {
        // 关键的逻辑在这里!
        require(msg.value >= prize || msg.sender == owner); // 必须满足这两个条件之一
        payable(king).transfer(msg.value); // 将当前奖金池的金额转给前任国王
        king = msg.sender;             // 新的发送者成为国王
        prize = msg.value;             // 新的奖金池等于发送者发送的金额
    }

    function _king() public view returns (address) {
        return king;
    }
}
  • constructor 当合约部署时,部署者 (msg.sender) 成为 owner 和初始的 king,并且 prize 被设置为部署时发送的以太坊金额。
  • receive() 函数: 这是整个游戏的“心脏”。任何发送以太坊到合约的人都会触发这个函数。
    • require(msg.value >= prize || msg.sender == owner); 这就是漏洞所在! 这个 require 语句要求:
      • 要么你发送的金额 (msg.value) 大于等于当前的 prize
      • 或者,发送者 (msg.sender) 是合约的 owner

揭秘攻击方式:巧妙利用 owner 权限

看到 || msg.sender == owner 这个条件了吗?这意味着,即使你发送的金额没有比 prize 高,但如果你是合约的 owner,你也可以成功触发 receive() 函数,并成为新的国王!

而 Ethernaut 关卡的 King.sol 合约,在部署时,它的 owner 就是 Ethernaut 关卡自己(部署者)。

所以,我们的攻击策略就呼之欲出了:

  1. 找到一个“中间人”: 我们需要一个能够直接与 King 合约交互的合约。这个新的合约,就充当了我们攻击的“载体”。
  2. 利用 owner 权限(但不是直接!): 我们不能直接成为 King 合约的 owner,因为 owner 在部署时就固定了。但我们可以 利用 King 合约的 receive() 函数的另一半逻辑:msg.value >= prize
  3. 精确计算,一击即中: 攻击合约 Hack.sol 的关键在于,它需要发送恰好等于当前 prize 的金额。这样,msg.value >= prize 这个条件就满足了。

攻击合约 Hack.sol 的奥秘:

让我们看看 Hack.sol 是如何做到的:

solidity
contract Hack {
    constructor(address payable _target) payable {
        // 1. 获取目标 King 合约当前的 prize
        uint prize = King(_target).prize();

        // 2. 向目标 King 合约发送一个金额,这个金额等于当前的 prize
        //    这样就满足了 receive() 函数中的 msg.value >= prize 条件
        //    即使 msg.sender 不是 owner,也能成功触发 receive() 并成为新的 king
        (bool success,) = _target.call{ value: prize }("");
        require(success, "Send to King ETH fail!");
    }
}
  • constructor(address payable _target):当 Hack 合约被部署时,它接收目标 King 合约的地址。
  • uint prize = King(_target).prize();Hack 合约首先会去查询目标 King 合约当前的 prize 是多少。
  • (bool success,) = _target.call{ value: prize }("");:这是关键的攻击点!Hack 合约通过 call 方法,向目标 King 合约发送了一个金额,这个金额恰好等于当前 prize 的值。
    • 由于 msg.value (我们发送的 prize 金额) 等于 prize,所以 msg.value >= prize 条件被满足。
    • Hack 合约因此成功地触发了 King 合约的 receive() 函数,并取代了原来的 king
    • 被替换的“前任国王”则收到了 Hack 合约发送的这笔以太坊。

总结:

Ethernaut 的“King”关卡是一个绝佳的例子,它展示了智能合约中看似微小的逻辑漏洞,如何被利用来颠覆游戏的规则。通过精确地理解合约的 require 条件,并利用 call 方法发送恰到好处的金额,我们可以巧妙地绕过“国王”的权力更迭,最终“赢得”这场区块链上的权力游戏。

记住,在智能合约的世界里,每一个细节都至关重要!

Built with AiAda