LESSON 01

EVM 基础

读懂链上交易的第一步,是知道代码在哪执行、状态写到哪里、调用者是谁,以及失败时哪些变化会被撤销。

约 75 分钟讲解 + 示例 + Lab + 自测本机记录进度
核心模型

审计不是找危险关键词

EVM 审计的本质,是追踪状态和价值如何经过一连串调用发生变化。

看到任何函数时,不要先搜索 calldelegatecall。先沿着执行路径回答下面七个问题:

调用者谁能进入这个函数?
输入哪些数据可能由攻击者控制?
读取决策依赖哪些旧状态?
写入哪些永久状态被改变?
外调控制权交给了谁?
价值余额与内部记账如何变化?
失败什么会回滚,什么仍然存在?
学习目标

学完本课,你应该能从原始 calldata 识别函数调用,解释代理的工作方式,并从 trace 还原一次多合约调用。

账户与交易:谁让状态机开始运行

以太坊有两类账户。EOA 由私钥控制,可以主动发起交易;合约账户由代码控制,只会在收到调用时执行。

EOA

外部拥有账户

  • 由私钥签名
  • 拥有 nonce 和余额
  • 可以发起一笔交易
  • 没有合约代码
CONTRACT

合约账户

  • 由部署代码定义行为
  • 拥有 storage 和余额
  • 只能被交易或合约调用触发
  • 不能自行在未来醒来

一笔交易至少要读懂这些字段

from

签名者恢复出的 EOA 地址,也就是交易的最初发起者。

to

入口地址。它可能是普通合约,也可能只是一个代理。

value

随交易发送的原生 ETH 数量,不等于 ERC-20 转账金额。

input

calldata。通常由 4 字节函数选择器和 ABI 编码参数组成。

receipt

执行后的状态、Gas 使用量、事件日志和合约创建地址。

容易混淆

所谓“内部交易”不是区块中的另一笔真实交易,而是执行 trace 中合约之间的内部调用。事件日志也不是状态本身。

四种数据位置,不同的生命周期

大量 Solidity 安全问题,都与“数据究竟放在哪里”有关。特别是可升级代理,storage 布局一旦错位,代码看似正确也会读写错误变量。

storage

永久状态

写入链上状态树,交易结束后仍存在。成本高,布局顺序会影响代理升级安全。

持久 / 可写
memory

调用期临时内存

只在当前调用期间存在,可以修改。函数结束后不会保存。

临时 / 可写
calldata

外部输入区域

包含外部调用参数,只读且通常比复制到 memory 更省 Gas。

临时 / 只读
stack

EVM 运算栈

opcode 操作的核心区域,每项 256 位,深度有限。初学不必背完 opcode。

运算 / 临时
contract Vault {
    uint256 public totalAssets; // storage

    function deposit(uint256 amount, bytes calldata proof) external {
        uint256 fee = amount / 100;
        totalAssets += amount - fee; // write storage
    }
}

ABI 与 calldata:从十六进制恢复意图

函数选择器是规范化函数签名 Keccak-256 哈希的前 4 字节。例如 transfer(address,uint256) 的选择器是 0xa9059cbb

4 bytesa9059cbbfunction selector
32 bytes000...recipientaddress 参数
32 bytes000...amountuint256 参数

静态类型通常直接放在 32 字节槽位中。动态类型会先放偏移量,再在后面存放长度与内容。

用 Foundry 工具验证

cast sig "transfer(address,uint256)"
# 0xa9059cbb

cast calldata "transfer(address,uint256)" 0x1111111111111111111111111111111111111111 1000000000000000000
不要靠事件猜参数

事件可以与状态变化不一致。恢复调用意图要从 calldata 和代码路径开始,再用事件与余额变化相互验证。

call、delegatecall 与 staticcall

三者最大的差别不是语法,而是代码在哪个上下文执行、谁的 storage 被写入,以及是否允许修改状态。

操作执行代码写入 storagemsg.sender
call目标合约目标合约当前调用合约
delegatecall目标合约调用方合约保留上层调用者
staticcall目标合约禁止写入当前调用合约
用户 EOA发送 calldata
代理合约保存状态与余额
delegatecall
实现代码提供执行逻辑
call
外部 Token自己的状态上下文
代理的关键

代理不是把自己的状态复制给实现。它是在代理上下文中运行实现代码,因此新逻辑可以继续读写原来的代理 storage。

失败与回滚:理解调用边界

当调用帧以 REVERT、异常或耗尽 Gas 失败时,该调用帧及其子调用造成的状态变化会被撤销。错误如果继续向上传播,整笔交易都会回到执行前状态。

1

上层写入 storage

2

调用外部合约

3

外部调用 revert

4

未捕获则全部回滚

但 Gas 已经被消耗,交易和失败结果仍会被区块记录。使用低级 call 时,上层可以读取返回的 success 并选择继续执行。

(bool success, bytes memory data) = target.call(payload);
if (!success) {
    failedAttempts[msg.sender] += 1;
}
动手实验

Lab:编码、读取、解码

目标不是记住命令,而是亲眼验证函数签名如何变成 calldata,链上调用如何产生返回值。

准备

确认 Foundry 已安装

forge --version
cast --version
anvil --version

三条命令都应该输出版本号。不要在这里输入私钥。

编码

生成 ERC-20 调用数据

cast calldata "balanceOf(address)" 0x0000000000000000000000000000000000000000

观察前 4 字节选择器和后面的 32 字节地址槽位。

读取

调用公开只读函数

cast call <TOKEN_ADDRESS> "balanceOf(address)(uint256)" <ACCOUNT_ADDRESS> --rpc-url <RPC_URL>

只调用你信任的 RPC,不要在命令历史中写 API Secret。

解释

留下实验记录

记录目标链、合约地址、函数签名、原始 calldata、解码结果和一个尚未理解的问题。

完成证据

保存一段真实 calldata,并逐字段标注 selector、参数类型与解码值。

课末自测

为什么代理合约升级逻辑后,用户余额等旧状态仍然存在?

信心
下一课钱包与操作安全