- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
CREATE2是 EIP-1014 引入的以太坊合约创建操作码,它用0xff ++ address ++ salt ++ keccak256(init_code)的确定性哈希取代了依赖nonce的CREATE地址推导,使合约地址变得完全可控、可预先计算。在 CTF 区块链题目中,这一特性最常见的利用方式是:保持init_code不变、让构造函数返回的运行时字节码可变,从而在同一个地址上先后部署字节码完全不同的合约,绕过各类基于地址或字节码的校验。阅读本篇后,你将掌握CREATE与CREATE2的地址推导原理、二者差异、地址预计算脚本,以及 2019 Balsn CTF Creativity 题目的完整 Solidity PoC 复现思路。
背景:为何 CREATE2 会在以太坊与 CTF 中被关注
在 CTF-Wiki 的区块链板块中,以太坊(Ethereum)安全是占比最高、也是最常出现的主题(见 区块链介绍)。智能合约代码一旦上链便难以篡改,因此合约部署地址的生成规则直接决定了攻击者能在多大程度上"预测"或"重放"合约。以太坊虚拟机(EVM)为开发者提供了两种创建合约的操作码:
CREATE:最传统的合约创建方式,地址由创建者地址与账户nonce决定;CREATE2:在"君士坦丁堡"(Constantinople)硬分叉中引入的新操作码,地址由创建者地址、盐值(salt)与创建代码(init_code)共同决定,完全不再依赖nonce。
两者都输出一个新合约的地址,但CREATE2让地址生成具有"确定性 + 可预计算"的能力,这正是其衍生出大量攻击技巧的根源。
CREATE 的地址推导:nonce 驱动的确定性
在理解CREATE2之前,需要先理解旧的CREATE操作码。无论使用外部账户(EOA)部署,还是由合约通过CREATE创建子合约,新合约的地址都由创建者的账户地址以及该账户的nonce共同决定。
以太坊中每个账户都维护着一个nonce字段(Ethereum Basics 将其定义为"已执行交易总数,用来标示该账户发出的交易数量"):
- 对外部账户而言,每发送一笔交易,
nonce就会+1; - 对合约账户而言,每通过
CREATE创建一个子合约,nonce就会+1。
新合约地址的计算公式为:
keccak256(rlp.encode(address, nonce))[12:]即:将创建者地址与当前nonce做 RLP 编码,取keccak256哈希结果的后 20 字节(160 位)作为新合约地址。
由于nonce会随交易或子合约创建次数而递增,同一账户两次创建合约得到的地址必然不同,而且地址无法在创建之前被精确预知(至少不直观)。这种"不可控性"正是CREATE2要解决的痛点。
CREATE2 的地址推导:EIP-1014 的确定性公式
CREATE2摒弃了对nonce的依赖,改为对以下三个参数做哈希运算:
| 参数 | 含义 | 可控性 |
|---|---|---|
address | 合约创建者的地址(部署方) | 部署方确定,可控 |
salt | 由调用方显式传入的 32 字节混淆值 | 完全可控 |
init_code | 合约的创建代码(用于部署合约的字节码) | 可控,是整个技巧的关键 |
其地址计算公式为:
keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12:]公式中0xff是一个固定的单字节前缀,用于与CREATE的地址空间(以及普通交易地址推导)作区分;随后拼接创建者地址、salt,再拼接init_code本身的keccak256哈希;最终对整个结果再取一次keccak256,并截取低 160 位(后 20 字节)作为新合约地址。
一个容易忽略的关键细节:init_code 与运行时字节码
计算地址时使用的最后一个参数并非合约最终存储的运行时字节码(runtime code),而是创建代码(init code)。创建代码是"用来创建合约的那段代码",它在合约创建交易执行期间运行,执行完毕后通过RETURN(0xf3)返回一段数据,这段数据才是写入链上的运行时字节码(参见 Ethereum Opcodes 中RETURN的语义:return memory[offset:offset+length])。
这一细节带来了极具攻击性的推论:只要init_code保持不变,无论构造函数最终返回什么运行时字节码,计算出的地址都完全一致。于是攻击者可以控制构造函数返回任意字节码,同时保持init_code固定,从而在同一个地址上反复部署、覆盖出完全不同行为的合约。
CREATE2允许合约在部署后被"重新更改"的特性天然存在潜在安全问题,这也是 EIP-1014 社区讨论(Potential Security Implications of CREATE2)所关注的重点。在 CTF 中,该技巧通常被用作"在同一地址部署不同合约,以绕过不同校验"的通用手段。
实战案例:2019 Balsn CTF Creativity 的 CREATE2 巧用
下面以 2019 Balsn CTF 的 Creativity 题目(基于其官方 Write-up 提供的 PoC)为例,完整演示如何借助CREATE2在同一个地址上先后部署两个完全不同的合约。
核心 PoC:Deployer 与 Dumper 两个合约
PoC 使用 Solidity^0.5.10,包含两个合约:Deployer负责执行CREATE2部署,Dumper则充当"始终不变的init_code",它唯一的职责就是在构造函数阶段把调用者传入的任意字节码"倾倒"(return)出来,作为最终部署的运行时字节码。
pragma solidity ^0.5.10; contract Deployer { bytes public deployBytecode; address public deployedAddr; function deploy(bytes memory code) public { deployBytecode = code; address a; // Compile Dumper to get this bytecode bytes memory dumperBytecode = hex'6080604052348015600f57600080fd5b50600033905060608173ffffffffffffffffffffffffffffffffffffffff166331d191666040518163ffffffff1660e01b815260040160006040518083038186803b158015605c57600080fd5b505afa158015606f573d6000803e3d6000fd5b505050506040513d6000823e3d601f19601f820116820180604052506020811015609857600080fd5b81019080805164010000000081111560af57600080fd5b8281019050602081018481111560c457600080fd5b815185600182028301116401000000008211171560e057600080fd5b50509291905050509050805160208201f3fe'; assembly { a := create2(callvalue, add(0x20, dumperBytecode), mload(dumperBytecode), 0x9453) } deployedAddr = a; } } contract Dumper { constructor() public { Deployer dp = Deployer(msg.sender); bytes memory bytecode = dp.deployBytecode(); assembly { return (add(bytecode, 0x20), mload(bytecode)) } } }逐段拆解这个 PoC 的攻击链路:
deploy(code)是唯一的对外入口:调用方把希望部署的任意合约字节码作为code参数传入。Deployer先将code存入公共状态变量deployBytecode(后面Dumper会读取它),再通过内联汇编执行CREATE2。init_code恒定:dumperBytecode硬编码了编译后的Dumper合约字节码,也就是说,无论传入什么code,CREATE2使用的init_code始终是同一段字节码,salt固定为0x9453,部署方地址固定为Deployer合约地址。三者不变 ⇒ 计算出的目标地址不变。Dumper构造函数"偷梁换柱":Dumper的构造函数在创建阶段执行,它会拿到msg.sender(即Deployer合约),调用deployBytecode()读出刚才传入的code,然后用内联汇编的return(add(bytecode, 0x20), mload(bytecode))把这整段code作为运行时字节码返回给 EVM。
需要注意汇编参数的内存布局:mload(bytecode)读取的是bytecode指针处的长度字段,add(bytecode, 0x20)则跳过长度字段指向数据区起始位置,二者恰好构成RETURN所需的(offset, length)参数——这也与 Ethereum Opcodes 中RETURN的栈语义完全一致。
效果:同一个地址,任意合约
对调用者而言,效果是惊人的:每次调用deploy(code),实际参与地址计算的init_code都是同一个dumperBytecode,加上固定的部署方地址与salt,因此deploy(code)部署出来的合约最终总是落在同一个地址上;而该地址上最终存储的运行时字节码,则是本次调用传入的code。
也就是说:
- 第一次
deploy(codeA)→ 地址 X 上部署的是codeA; - 第二次
deploy(codeB)→地址 X 上部署的变成了codeB; - 无论传入什么合约代码,都能"精确投递"到同一个地址。
这正好实现了本文开头所说的"在同一次 CTF 挑战中,用同一个地址绕过不同校验"的攻击模式:题目校验某地址上的合约行为时,攻击者可以先在该地址部署满足校验 A 的合约,再重新部署满足校验 B 的合约。
地址预计算:getAddress 函数
由于CREATE2的地址完全确定,攻击者在部署前就能算出目标地址。题目给出的已知条件是Deployer合约地址为0x99Ed0b4646a5F4Ee0877B8341E9629e4BF30c281,配合固定的dumperBytecode与salt,可以预先算出实际部署合约的地址为0x4315DBef1aC19251d54b075d29Bcc4E81F1e3C73。
地址预计算的 Solidity 实现如下,其计算逻辑与keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12:]一一对应:
function getAddress(address addr, bytes memory bytecode, uint salt) public view returns (address) { bytes32 hash = keccak256( abi.encodePacked( bytes1(0xff), addr, salt, keccak256(bytecode) ) ); // NOTE: cast last 20 bytes of hash to address return address(uint160(uint256(hash))); }实现细节解读:
abi.encodePacked将0xff、创建者地址、salt与keccak256(init_code)紧凑拼接,保证不引入任何填充字节,与 EVM 内部计算逻辑完全一致;bytes1(0xff)对应公式中的固定前缀;- 最后通过
uint160(uint256(hash))截取哈希的低 20 字节并转换为address类型。
实际复现时,可以先用getAddress验证预计算地址0x4315DBef1aC19251d54b075d29Bcc4E81F1e3C73,再分别在deploy中传入两段不同的字节码,观察同一地址上先后部署出两个不同合约。
部署过程实测截图
下面的两张图来自本题的复现过程,直观展示了"同一个地址上先后部署两个不同合约"的完整链路。
两张图中,部署方(DEPLOYER)地址均为0x99Ed0b4646a5F4Ee0877B8341E9629e4BF30c281,但传入的待部署字节码参数不同(第一张为0x33ff,第二张为完整合约字节码),两次部署计算出的合约地址却都是0x4315DBef1aC19251d54b075d29Bcc4E81F1e3C73——这正是CREATE2地址确定性在实战中的直接体现。
实战中的攻击套路与防御视角
为什么攻击者偏爱 CREATE2
从 CTF 出题与解题两个角度看,CREATE2的核心价值在于:
- 地址可预计算:部署前即可获知目标地址,便于配合其他合约预先设置白名单、授权等依赖地址的逻辑;
- 地址可复用:同一地址可以承载完全不同的代码逻辑,从而绕过"地址 A 已经存在/不可修改"之类的假设;
init_code与运行时字节码解耦:只要控制好构造函数返回的数据,就能让地址计算与最终代码"脱钩"。
对应的防御注意事项
- 合约逻辑中若把"某地址上已部署的代码"视为不可变信任根,需要意识到该地址可能被
CREATE2重新部署出恶意代码(即所谓的"合约可以自我替换"风险); - 审计中应特别关注项目方是否使用了固定
salt+ 可变init_code的模式,以及构造函数是否会返回来自外部输入的字节码; - 在编写依赖合约地址做权限控制的系统时,应显式验证部署方身份与部署时间,而不是只依赖地址本身。
相关 CTF 题目
下列题目均与CREATE2技巧相关,适合作为练习(题目附件可参考 CTF-Wiki 的 challenges 仓库):
| 赛事 | 题目名称 |
|---|---|
| Balsn 2019 | Creativity |
| QWB 2020 | EasyAssembly |
其中 Balsn 2019 Creativity 正是上文 PoC 的出处;QWB 2020 EasyAssembly 则将CREATE2与汇编/字节码构造技巧结合,进一步考验对 EVM 执行细节的把控。
参考
- EIP-1014: Skinny CREATE2:
CREATE2操作码的正式规范,定义了地址计算公式与引入背景; - 充分利用 CREATE2:社区对
CREATE2各类用法的系统梳理; - Balsn CTF 2019 - Creativity:本题的官方 Write-up 与 PoC 原始出处。
如需回顾相关基础知识,可继续阅读 Ethereum Basics(账户、nonce、交易与合约创建机制)以及 Ethereum Opcodes(RETURN、CALL等关键指令的栈语义)。
- 文档
- 网络安全
- 教程
【免费下载链接】ctf-wiki
Come and join us, we need you!
相关推荐
Envoy 静态配置详解:用 static_resources 搭建一个可运行的 HTTP 反向代理
Envoy 静态配置详解:用 static_resources 搭建一个可运行的 HTTP 反向代理 本文以 Envoy 官方快速上手指南中的「静态配置」文档为
文档网络安全教程WTF Solidity 极简入门:25. CREATE2 操作码——在合约部署前预计算合约地址
WTF Solidity 极简入门:25. CREATE2 操作码——在合约部署前预计算合约地址 CREATE2 操作码允许开发者在不实际部署合约的情况下,通过
示例工程区块链教程Wasp 邮件功能中的 Dummy Provider:仅限开发使用的邮件发送器与生产构建限制
Wasp 邮件功能中的 Dummy Provider:仅限开发使用的邮件发送器与生产构建限制 在 Wasp(一个面向全栈 Web 应用的声明式框架)中, ema
文档网络安全教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考