22-ETH-智能合约
本文介绍了智能合约的核心结构与开发要点,包括:1)合约基础结构(许可证、版本声明、主要元素);2)数据类型分类(值类型与引用类型);3)函数修饰符(内置修饰符、状态与行为修饰符、自定义修饰符);4)存储机制(存储位置、赋值方式、存储槽规则及Gas优化技巧)。文章通过代码示例展示了银行合约的实现,并详细解析了Solidity 0.8.x版本的改进特性,为智能合约开发提供实用参考。
学习视频来源:https://www.bilibili.com/video/BV1Vt411X7JF/?p=22
本博客除了包含自己的在学习过程中记录的笔记外,还包含少部分自己扩展的内容,如有错误,敬请指正。
智能合约要讲的东西是很多的,课上讲了一些核心基础,我这里整理一版更详细的基础和经典的合约及合约攻击。
文章目录
- 1. 合约结构
- 2. 数据类型
- 3. 函数修饰符
- 4. 存储机制
- 5. 异常 revert/required/error/assert对比
- 6. ETH 转账方式transfer/send/call对比
- 7. CREATE vs CREATE2
- 8. 经典合约(ERC20/721/1155)
- 9. 经典合约攻击
1. 合约结构
- 许可证:// SPDX-License-Identifier: MIT
- 版本声明:用是限定编译器的可用版本范围。如:pragma solidity ^ 0.8.20( ^兼容该版本及以上小版本,>=0.8.0 <0.8.21精确范围)。0.8.x 核心改进:内置整数溢出/下溢检查、引入calldata优化、支持自定义错误。版本不匹配,大概报错。计算不报错也可能有安全问题。同一份代码不同编译器版本编译的结果可能不一样。
- 主要元素:接口、库、状态变量、构造函数、成员函数。
合约有普通合约和抽象合约,抽象合约不能直接部署,需要子合约实现抽象方法后才能部署。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleBank {
// ===== 状态变量 =====
mapping(address => uint256) public balances;
address public owner;
uint256 public totalDeposits;
// ===== 自定义错误(推荐替代 require + string)=====
error InsufficientBalance();
error InvalidAmount();
// ===== 事件 =====
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
// ===== 构造函数 =====
constructor() {
owner = msg.sender;
}
// ===== 存款函数(接收 ETH)=====
receive() external payable {
deposit();
}
function deposit() public payable {
if (msg.value == 0) revert InvalidAmount();
balances[msg.sender] += msg.value;
totalDeposits += msg.value;
emit Deposited(msg.sender, msg.value);
}
// ===== 取款函数 =====
function withdraw(uint256 amount) public {
if (amount == 0) revert InvalidAmount();
if (balances[msg.sender] < amount) revert InsufficientBalance();
balances[msg.sender] -= amount;
totalDeposits -= amount;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
emit Withdrawn(msg.sender, amount);
}
// ===== 查询余额(view 函数)=====
function getBalance(address user) public view returns (uint256) {
return balances[user];
}
// ===== 只有 owner 能调用的函数 =====
function withdrawAllToOwner() external {
require(msg.sender == owner, "Only owner");
uint256 amount = address(this).balance;
(bool sent, ) = owner.call{value: amount}("");
require(sent, "Transfer failed");
}
}
2. 数据类型
2.1 值类型
| 类型 | 说明 |
|---|---|
int8 ~ int256 |
有符号整数,32个 |
uint8 ~ uint256 |
无符号整数(默认 uint = uint256),32个 |
address |
普通地址(不可转账) |
address payable |
可接收 ETH 的地址(支持 .transfer() / .send()) |
bool |
true / false |
bytes1 ~ bytes32 |
固定长度字节(bytes32 最常用),一共32个 |
enum |
自定义枚举,如 enum Status { Active, Inactive } |
2.2 引用类型
| 类型 | 存储位置限制 | 说明 |
|---|---|---|
string |
storage / memory / calldata |
动态长度 UTF-8 字符串 |
bytes |
同上 | 动态字节数组(比 string 更底层) |
| 固定数组 | 如 uint[5] |
长度编译时确定 |
| 动态数组 | 如 uint[] |
长度可变 |
struct |
可嵌套组合字段 | 用户自定义复合类型 |
mapping |
仅限 storage |
键值对,不能用于 memory 或 calldata |
3. 函数修饰符
3.1 内置修饰符
| 修饰符 | 调用范围 | 典型用途 | 注意事项 |
|---|---|---|---|
public |
内部 + 外部 | 对外暴露的核心函数 | 状态变量加 public 自动生成 getter |
external |
仅外部(EOA/其他合约) | 高频外部调用(省 Gas) | this.f() 会触发外部调用,msg.sender 改变 |
internal |
本合约 + 继承合约 | 内部逻辑封装 | 外部不可访问 |
private |
仅本合约 | 核心私有逻辑 | 继承合约也无法访问 |
3.2 状态与行为修饰符
| 修饰符 | 行为约束 | 可访问内容 | 用途 |
|---|---|---|---|
view |
只读 storage | 可读状态变量,不可写 | 查询函数 |
pure |
不读不写 storage | 不能访问状态变量、msg、block 等 |
纯计算(如数学工具) |
payable |
可接收 ETH | — | 转账、充值等 |
virtual |
允许子类重写 | — | 父合约中声明可扩展方法 |
override |
重写父类/接口方法 | — | 必须搭配 virtual 使用 |
3.3 自定义修饰符(modify)
当一个函数有多个修饰符时,执行顺序:所有的前置逻辑 -> 函数本地 -> 所有后置逻辑。前置从右到左,后置从左到右,函数本体在中间:整体是 “洋葱模型”;function test() mod1 mod2mod1(mod2(test()))
4. 存储机制
4.1 存储位置
- storage:持久化存储(状态变量)
- memory:临时内存(函数内)
- calldata:只读外部调用参数(最省 Gas)
4.2 赋值方式
- 值类型之间的拷贝都是深拷贝,传递的是值。
- 不同类型直接的赋值都是深拷贝。同类型之间有深有浅。
- storage变量:有深有浅。在函数内拷贝给另一个在函数内定义的storage就是浅拷贝,拷贝的引用。但是2个状态变量之间的赋值,就是深拷贝。
memeroy:赋值是浅拷贝,只拷贝引用。需手动复制数据内容实现深拷贝,与java类似。可能内存会丢,但在函数执行后会被回收。
4.3 存储槽
基本类型存储
- 1个槽能存32个字节,按声明顺序打包,能放则放,不同类型的数据也可以放同一个槽。
- 所有基础值类型(如 address、uint、bool 等)都是“原子性”的,必须完整存放在同一个存储槽中,不能跨槽拆分。目的方便读取。
- 槽号从0开始
- 同一个槽先填的变量占高位,后填的占低位。和EVM设计有关。
复杂类型存储
1.定长数组:元素连续存储,起始位置是该变量被分配的槽。
2.动态数组:长度存储在变量槽p,元素存储起始位置为keccak256(abi.encodePacked(uint256§))。
映射:映射本身不占用存储空间(只预留一个槽位),元素位置通过公式计算:keccak256(abi.encodePacked(uint256(key), uint256§))。
几乎不可能映射同一位置。p参与计算的目的:没有p,不同的映射相同的key,映射到同一槽号。abi.encodePacked就是紧凑打包编码为字节数组(返回值)的意思,如果有多个参数会拼接。
3.结构体:成员变量按声明顺序连续分配槽位。
4.4 Gas优化
storage优化
- 变量打包
- 复用sotrage读写结果:冷读->热读
Storage 冷读(首次读)2100 Gas,热读(缓存读)仅 100 Gas;多次使用的 Storage 变量,先读入 Memory 复用,避免重复冷读。 - 数组长度用变量替换
如遍历storage 数组(如 for (uint i=0; i<arr.length; i++),arr.length会触发多次冷读。用局部变量替换uint len = arr.length;
memory优化
-
避免 Memory 数组的无意义扩容
-
用值类型替代动态类型(Memory 中)
string/bytes 等动态类型在 Memory 中需额外存储长度,成本略高;能用 bytes32/uint 替代的优先替换。
calldata优化
参数如果不改的话,用calldata,不要用memeroy。
语法优化
- 用 unchecked 减少溢出检查(确定无溢出时)、
- 用 require 替代 assert(错误处理)require 用于业务逻辑校验(如余额不足),失败时返还剩余 Gas;assert 用于代码逻辑校验(如溢出),失败时消耗全部 Gas。
- 避免浮点运算 / 复杂数学运算。EVM 无原生浮点运算,用库实现(如 ABDKMath)会消耗大量 Gas;能用整数运算替代的优先替换。如百分比。
- 开启编译器优化
5. 异常 revert/required/error/assert对比
- revert(“msg”) 手动抛出带字符串的错误,灵活但 gas 开销大,失败时 revert 并退回剩余gas。
- require是revert的简洁版,适合业务逻辑异常,失败时 revert 并退回剩余 gas。
- error MyError(…) 是 Solidity 0.8.4+ 推荐的结构化错误机制,gas 更省、支持参数、前端易解析结果。
- assert 用于检查内部逻辑不变式(如程序 bug),绝不应被触发,仅作调试用途。
推荐使用error,因为其更省gas,且前端易解析结果。 因为:
revert
revert("Insufficient balance");
编译时,完整字符串"Insufficient balance"(21 字节)会被硬编码进合约 bytecode。每次触发错误时,EVM 需要将这21字节(+填充到 32 字节)作为返回数据复制出去。gas 开销高。
error
error InsufficientBalance();
revert InsufficientBalance();
编译时,只存储4字节的函数选择器:bytes4(keccak256(“InsufficientBalance()”))→例如0x12345678。触发时,revert数据只有这4字节+ABI填充(共32字节,但有效数据仅4字节),gas开销低。部署时节省大量bytecode空间执行时复制的数据量小得多error确实可以携带参数并不显著增加 gas 开销参数只在运行时传递,不存储在合约 bytecode中。
以下是您提供内容的整理版,已按要求转换为 Markdown 表格,未更改原始信息:
6. ETH 转账方式transfer/send/call对比
| 项目 | transfer(uint256 amt) |
send(uint256 amt) |
call{value: amt}("") |
|---|---|---|---|
| 单位说明 | amt 可为 wei/gwei/ether,默认 wei;推荐用 1 ether 写法避免大数错误 |
同左 | 同左 |
| 返回值 | 无 | bool |
(bool, bytes)(成功返回 (true, ""),失败返回 (false, 错误信息)) |
| 失败处理机制 | 失败自动 revert |
失败返回 false,不 revert,需手动处理 |
失败时仅返回 false,不 revert,需手动处理 |
| Gas 限制 | 2300(不足以触发重入攻击) | 2300(不足以触发重入攻击) | 不限制(重入风险高) |
| 使用场景 | 1. 适合给普通用户(EOA)转账(无 receive/fallback,无风险,且无需 gas)2. 也可给合约转账 ETH,但仅当接收合约的 receive/fallback 消耗 Gas ≤ 2300 时成功;否则 revert。此时应使用 call。 |
需要失败后自定义处理逻辑 | 1. 给复杂合约转账2. 失败后需自定义处理逻辑3. 希望获得失败详情4. 需要自定义 gas 上限:to.call{value: amt, gas: 100000}("")(不定义则使用剩余全部 gas) |
| 关系 | 是对底层 call 操作码的更高层封装 |
是对底层 call 操作码的高层封装 |
是对底层 call 操作码的封装。call 操作码是实现跨账户调用(含 ETH 转账)的唯一操作码! |
7. CREATE vs CREATE2
| 项目 | CREATE(普通部署) |
CREATE2(确定性部署) |
|---|---|---|
| 语法 | address addr = address(new Deployer()); |
address addr = address(new Deployer{salt: salt}()); |
| 地址决定因素 | 部署者地址 + 部署者 nonce | 部署者地址 + salt + 合约字节码哈希 |
| 地址是否可预测 | 否(nonce 随部署次数变化) | 是(部署前就能计算) |
| 能否复用地址 | 否(nonce 递增,地址必变) | 能(销毁旧合约后,可重新部署到同一地址) |
8. 经典合约(ERC20/721/1155)
8.1 ERC20 标准(同质化代币)
| 函数/事件 | 描述 |
|---|---|
name() public view virtual returns (string memory) |
币名 |
symbol() public view virtual returns (string memory) |
币符号 |
decimals() public view virtual returns (uint8) |
币精度 |
allowance(address owner, address spender) public view virtual returns (uint256) |
查询 spender 拥有 owner 的额度 |
approve(address spender, uint256 value) public virtual returns (bool) |
调用者授予额度给 spender |
transfer(address to, uint256 value) public virtual returns (bool) |
调用者将钱直接转给 to |
transferFrom(address from, address to, uint256 value) public virtual returns (bool) |
spender 调用,将 from 的钱转给 to,使用后扣减 spender 额度 |
_mint(address account, uint256 value) internal |
给 account 铸 value 个币 |
_burn(address account, uint256 value) internal |
销毁 account 的 value 个币 |
event Transfer(address indexed from, address indexed to, uint256 value) |
转移事件 |
event Approval(address indexed owner, address indexed spender, uint256 value) |
授予额度事件 |
注:ERC20 是一个同质化代币抽象合约,子合约必须实现构造函数,传入
name和symbol。可调用_mint铸币给部署者,指定总量。ERC20 经多次审核,一般无 bug,问题多出现在二次开发中。
8.2 ERC721 标准(非同质化代币)
状态变量
| 变量 | 说明 |
|---|---|
string private _name; |
代币名称 |
string private _symbol; |
代币符号 |
mapping(uint256 tokenId => address) private _owners; |
代币持有者地址 |
mapping(address owner => uint256) private _balances; |
持有者一共多少个代币 |
mapping(uint256 tokenId => address) private _tokenApprovals; |
单个代币 → 授权地址 |
mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; |
全量代币 → 授予地址(与 _tokenApprovals 互补) |
mapping(uint256 tokenId => string) private _tokenURIs; |
代币的 tokenUri(在 ERC721URIStorage 合约中) |
构造函数
constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_);
查询函数
| 函数 | 说明 |
|---|---|
balanceOf(address owner) public view virtual returns (uint256); |
查某人拥有多少 NFT |
ownerOf(uint256 tokenId) public view virtual returns (address); |
查 tokenId 的主人是谁 |
tokenURI(uint256 tokenId) public view virtual returns (string memory); |
查该 token 元数据地址 |
转移函数
| 函数 | 说明 |
|---|---|
transferFrom(address from, address to, uint256 tokenId) public virtual; |
转移 from → to |
safeTransferFrom(address from, address to, uint256 tokenId) public virtual; |
安全转移:调用 to.onERC721Received,验证是否返回正确 selector(防止转入黑洞合约) |
safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual; |
安全转移,带 data 字段(用于向合约传递额外信息,如市场、质押池等) |
授权函数
| 函数 | 说明 |
|---|---|
approve(address to, uint256 tokenId) public virtual; |
给某人授予权限(单个 token) |
setApprovalForAll(address operator, bool approved) public virtual; |
给某人授予全部 NFT 权限 |
getApproved(uint256 tokenId) public view virtual returns (address); |
获取 tokenId 当前的被授权者 |
isApprovedForAll(address owner, address operator) public view virtual returns (bool); |
查询 operator 是否被 owner 全量授权 |
操作条件:满足以下任一即可操作 NFT:
msg.sender == owner或msg.sender == getApproved(tokenId)或isApprovedForAll(owner, msg.sender)
8.3 ERC1155 标准(多代币标准)
状态变量
| 变量 | 说明 |
|---|---|
mapping(uint256 => mapping(address => uint256)) private _balances; |
某个代币 ID,某人拥有多少 |
mapping(address => mapping(address => bool)) private _operatorApprovals; |
批量授权(类似 ERC721,但 无单个代币授权) |
string private _uri; |
代币元数据 URI 模板 |
构造函数
constructor(string memory uri_) public;
查询函数
| 函数 | 说明 |
|---|---|
balanceOf(address account, uint256 id) public view returns (uint256); |
查某人持有某代币的份数 |
balanceOfBatch(address[] memory accounts, uint256[] memory ids) public view returns (uint256[] memory); |
批量查询(accounts 与 ids 长度必须一致) |
isApprovedForAll(address account, address operator) public view returns (bool); |
查询是否全量授权 |
uri(uint256 id) public view returns (string memory); |
返回代币元数据 URI(通常含 {id} 占位符) |
转移函数
| 函数 | 说明 |
|---|---|
safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes memory data) public; |
安全单笔转移 |
safeBatchTransferFrom(address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) public; |
安全批量转移 |
注:
data字段用于向接收合约传递额外上下文(如游戏道具配置、市场订单 ID 等)
授权函数
| 函数 | 说明 |
|---|---|
setApprovalForAll(address operator, bool approved) public virtual; |
给某人授予全部代币操作权限 |
isApprovedForAll(address owner, address operator) public view virtual returns (bool); |
查询是否全量授权 |
注意:ERC1155 没有类似 ERC721 的
approve(address to, uint256 tokenId)单 token 授权机制!
内部函数(用于扩展)
| 函数 | 说明 |
|---|---|
_setURI(string memory newUri) internal; |
设置新 URI 模板 |
_mint(address account, uint256 id, uint256 amount, bytes memory data) internal; |
铸造单种代币 |
_mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) internal; |
批量铸造 |
_burn(address account, uint256 id, uint256 amount) internal; |
销毁单种代币 |
_burnBatch(address account, uint256[] memory ids, uint256[] memory amounts) internal; |
批量销毁 |
_beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) internal virtual; |
转移前钩子 |
_afterTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) internal virtual; |
转移后钩子 |
9. 经典合约攻击
主要参考 https://solidity-by-example.org
9.1 重入攻击
合约
存钱,提款
攻击者

9.2 算术溢出攻击
合约
调用往里边存钱,但是要锁定至少7天,可以增加锁定时间。
攻击者

9.3 delegatecall攻击
问题在于逻辑合约和代理合约存储布局不一致,导致代理合约中的有些关键的数据可能篡改。然后在使用篡改后的数据干坏事。
合约

攻击者

9.4 随机数攻击
合约
利用前一个区块哈希和当前区块时间戳当随机数,猜对了就转账给猜的人。漏洞在于猜数和验证是在同一个交易里,区块哈希、时间戳等都是相同的。攻击者按规则生成数字,肯定能猜中。

9.5 拒绝服务攻击
合约
谁存进来的钱多谁当国王,如果有人成了新国王,合约要把老国王的钱退回。
攻击者
故意不实现receive/fallback,根本收不了钱。合约就没办法转给他
解决方式:合约不主动赚钱给参与者,提供提款函数,让参与者自己提。
9.6 txorgin钓鱼
合约
外部用户A部署一个合约,合约的owner就是A的地址。调用transefer,但只有A有权限,通过tx.origin == owner实现
攻击者
钓鱼攻击,比如伪装成一个链接。让用户A点一下,实际上是让A发起了一笔交易,to是攻击者的地址。就把A的钱转移至攻击者。
解决方法
改成msg.sender == owner。再被钓鱼时,传过来的msg.sender是攻击合约的地址。
9.7 Honeypot(蜜罐陷阱)
这个很有意思,这个是反治黑客的例子,不是被攻击的例子。
部署一个表面上存在重入攻击漏洞的Bank合约和一个正常Logger合约,引诱黑客来攻击。但用部署的时候,用HoneyPot 合约地址替换Logger合约地址,伪装成Logger。
黑客上当链路:
1.Attack先存钱,然后通过通过Attack合约调用Bank.withdraw()提款。
2.Bank.withdraw() 先校验余额,然后调用msg.sender.call{value: 1 ether}(“”)给Attack转 1 ETH;
3.Attack的fallback()被触发,检测到Bank还有余额(1 ETH),再次调用Bank.withdraw(1 ether)(重入攻击);
4.多次重入后,当最后一次Bank.withdraw()执行到末尾时,会调用logger.log(…)(此时logger是HoneyPot);HoneyPot.log()检测到_action是 “Withdraw”,执行revert(“It’s a trap”),整个交易直接回滚。
正常用户或合约调用withdraw也和Attack一样,交易都会回滚,因为不重入,所以只烧一次。这不是个正常的Bank合约,而是用来专门诱捕黑客的合约,烧他的gas。
Bank和Logger合约

HoneyPot合约

黑客发起重入攻击合约

9.8 区块时间戳操作攻击
合约
轮盘游戏:
- 玩家调用 spin() 函数,并发送 10 ETH。
- 如果当前区块的时间戳(block.timestamp)能被 15 整除(即 block.timestamp % 15 == 0),玩家就赢得合约中所有 ETH。
- 每个区块只允许一次下注(通过 pastBlockTime 防止同一区块多次调用)。
- 部署合约时可以附带 ETH(constructor() payable {}),通常会预先存入 10 ETH 作为奖池

攻击者
以太坊协议规定矿工可以矿工可以在一定范围内(通常 ±15 秒内)调整时间戳;时间戳必须大于前一个区块,且不能超过当前网络时间太多。攻击者可以作为矿工,当挖到一个新区块时,故意将block.timestamp设置为能被15整除的值,该区块中包含自己的spin()交易。获取奖励。
解决方案: 永远不要依赖block.timestamp等作为不可预测的随机源或关键胜负判定依据。
9.9 绕过合约代码大小攻击
合约
本意是通过isContract函数判断调用者是否为合约地址,将protected函数限制仅外部 EOA 账户(普通钱包) 可调用,调用成功会将pwned设为true。
攻击者
一个合约在构造函数执行期间,其代码还未最终写入区块链,此时extcodesize(address(this))返回 0,会被isContract误判为“非合约地址”,就可以调用protected函数了。
部署完成再调用是攻击不成功的
9.10 抢跑攻击
合约
玩家找一个值solution,使得其哈希值=给定的哈希值。
攻击者
攻击者可通过监听未打包的交易,用更高的 Gas 价格抢先提交相同答案,夺走奖励。
解决方案:针对这类猜谜合约,行业通用的修复思路是 先提交哈希(solution的盐值加密后的值),后验证答案(Commit-Reveal 模式),核心是让答案在提交阶段不可见,验证阶段才公开。验证时提供solution 和slat
9.11 合约替换攻击
https://solidity-by-example.org/hacks/deploy-different-contracts-same-address/
如何替换一个已部署的合约,同地址替换代码?
-
直接用 CREATE2 部署 Proposal 不可行
CREATE2 部署合约的地址由「部署者地址 + salt 值 + 合约字节码哈希」三者唯一决定。只要合约字节码从 Proposal(合法)变为 Attack(恶意),字节码哈希必然改变,最终生成的地址也会完全不同,无法实现 “同地址替换代码” 的目标。 -
必须用 CREATE 部署子合约(Proposal/Attack)
CREATE 部署子合约的地址仅由「部署者(父合约)地址 + 部署者 nonce(部署次数)」决定,与子合约字节码无关。这意味着:只要部署子合约的父合约地址和 nonce 不变,无论部署的是 Proposal 还是 Attack,子合约地址都会完全一致 —— 这是实现 “地址不变、代码替换” 的核心前提。 -
保证父合约地址不变:若直接用 CREATE 部署父合约(Deployer),父合约地址由攻击者钱包地址 + 钱包 nonce决定,钱包 nonce 会随交易递增,销毁旧父合约后重新部署的新父合约地址必然改变。因此必须对父合约(Deployer)使用 CREATE2 部署:CREATE2 的地址由DeployerDeployer 地址 + 固定 salt 值 + Deployer 字节码决定,只要这三个要素不变,即使销毁旧父合约,重新部署的新父合约地址也会和旧地址完全一致,实现父合约地址的 “永久固定”。
-
保证父合约 nonce 不变:销毁并重置父合约:旧父合约部署 Proposal 后,其 nonce 会从 0 变为 1(部署一次子合约,nonce+1)。若不销毁旧父合约,即使地址不变,调用deployAttack()时 nonce 会变为 2,生成的 Attack 地址仍与 Proposal 地址(Addr_X)不同。因此必须先调用selfdestruct销毁旧父合约:合约被销毁后,其地址变为 “空合约”,代码和存储清空,nonce 也随之重置。此时通过 CREATE2 重新部署的新父合约,地址仍为原地址,且 nonce 恢复为初始值 0—— 部署 Attack 时 nonce 变为 1,与部署 Proposal 时的 nonce 完全一致。
-
最终实现合约替换:新父合约(地址不变、nonce=0)调用deployAttack()部署恶意 Attack 合约时,因 CREATE 部署规则,Attack 的地址与原 Proposal 的地址(Addr_X)完全重合,且字节码替换为恶意逻辑,最终实现 “复用合法地址、执行恶意代码” 的攻击目标。
合约替换攻击的本质: “CREATE2 固定父地址 + 部署子合约 + 重置父nonce + CREATE 复刻子地址”
- 对父合约用 CREATE2:锁定父地址,保证销毁后可复用;
- 对子合约用 CREATE:让子地址仅依赖父地址 + nonce,与代码无关;
- 销毁父合约:重置 nonce 为 0,复刻子合约部署时的状态;
- 最终实现:Attack 与 Proposal 地址完全一致,代码却被替换为恶意逻辑。
更多推荐
所有评论(0)