LESSON 01

EVM 基础

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

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

审计不是找危险关键词

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

四层半透明材料叠在深色工作台上,对应 storage、memory、calldata 与 stack 不同的生命周期
状态是底下那层沉的东西。调用只是暂时把光打上去。审计要问的是:光走了之后,哪一层还留下痕迹。

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

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

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

视频

先建立“世界计算机”的画面

文字适合精确,视频适合建立空间感。看完后回到上面七个问题,检查自己能不能用自己的话重讲一遍。

Ethereum Explained: The EVM从状态机、Gas 和 opcode 成本讲起。不必一次记完数字,抓住“每一步都有价格,写存储特别贵”即可。

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

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

EOA

外部拥有账户

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

合约账户

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

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

from

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

to

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

value

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

input

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

receipt

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

容易混淆

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

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

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

交互图

点一层,看它活多久、能不能改

大量 Solidity 事故不是语法错,而是数据放错了地方,尤其是 storage 布局和 calldata 解码。

storage

写入状态树,交易结束后还在。代理升级时,变量顺序就是布局。

映射、余额、owner、paused 都属于这里。读便宜,写很贵。布局错位时,新实现会把旧变量读成别的槽。

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。

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

交互实验

把函数变成 calldata

改签名或参数,立刻看到 4 字节选择器和后面的 32 字节槽位。这就是链上 `input` 字段。

4 bytesa9059cbbselector
32 bytes · arg 100000000000000000000000011111111111111111111111111111111111111110x1111111111111111111111111111111111111111
32 bytes · arg 20000000000000000000000000000000000000000000000000de0b6b3a76400000x0000000000000000000000000de0b6b3a7640000
0xa9059cbb00000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000de0b6b3a7640000
不要靠事件猜参数

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

call、delegatecall 与 staticcall

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

不透明铁盒与透明玻璃板并置,光线穿过玻璃打在铁盒上,比喻实现代码在代理的存储上下文中执行
代理是铁盒:它保管状态。实现是玻璃:它提供逻辑。delegatecall 是让玻璃上的光,去改铁盒里面的东西。
操作执行代码写入 storagemsg.sender
call目标合约目标合约当前调用合约
delegatecall目标合约调用方合约保留上层调用者
staticcall目标合约禁止写入当前调用合约
交互实验

同一次 setOwner,三种调用完全不同

用户对 Proxy 调用 `setOwner(0xAttacker)`。真正被改掉的 storage,取决于这是 call、delegatecall 还是 staticcall。

EOAUsermsg.sender 起点
msg.sender 保留
Proxy保存状态owner 0xAliceETH 100
delegatecall
Logic提供代码owner 0xBobETH 0

还没有执行。选择一种调用后点运行。

代理的关键

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

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

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

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

交互实验

外部调用失败后,状态还在不在?

上层先 `totalAssets += 1`,再调用外部合约。外部合约 revert。差别只在于上层有没有检查 `success`。

  1. Vault 把 totalAssets 写成 11
  2. Vault 调用外部 Token
  3. Token revert
  4. 错误向上传播,整笔交易回滚
最终 storage
totalAssets = 10
receipt
尚未发送
Gas
0
(bool success, bytes memory data) = target.call(payload);
if (!success) {
    failedAttempts[msg.sender] += 1;
}
动手实验

Lab:编码、读取、解码

目标不是记住命令,而是亲眼验证函数签名如何变成 calldata。下面可以先在网页里跑一遍,再去自己的终端复现。

网页终端

用 cast 看选择器怎么来

这是本地 Foundry 命令的可重复演示。不会连接你的钱包,也不会发送交易。

点运行,逐步看到命令和结果。这里不会碰到你的真实钱包。

完成证据

保存一段真实 calldata,并逐字段标注 selector、参数类型与解码值。可以截图本页的交互结果,再对照一条主网交易的 input。

课中测验 1 / 4

代理升级实现合约后,用户余额为什么通常还在?

课末自测

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

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