审计不是找危险关键词
EVM 审计的本质,是追踪状态和价值如何经过一连串调用发生变化。
看到任何函数时,不要先搜索 call 或 delegatecall。先沿着执行路径回答下面七个问题:
学完本课,你应该能从原始 calldata 识别函数调用,解释代理的工作方式,并从 trace 还原一次多合约调用。
账户与交易:谁让状态机开始运行
以太坊有两类账户。EOA 由私钥控制,可以主动发起交易;合约账户由代码控制,只会在收到调用时执行。
外部拥有账户
- 由私钥签名
- 拥有 nonce 和余额
- 可以发起一笔交易
- 没有合约代码
合约账户
- 由部署代码定义行为
- 拥有 storage 和余额
- 只能被交易或合约调用触发
- 不能自行在未来醒来
一笔交易至少要读懂这些字段
from签名者恢复出的 EOA 地址,也就是交易的最初发起者。
to入口地址。它可能是普通合约,也可能只是一个代理。
value随交易发送的原生 ETH 数量,不等于 ERC-20 转账金额。
inputcalldata。通常由 4 字节函数选择器和 ABI 编码参数组成。
receipt执行后的状态、Gas 使用量、事件日志和合约创建地址。
所谓“内部交易”不是区块中的另一笔真实交易,而是执行 trace 中合约之间的内部调用。事件日志也不是状态本身。
四种数据位置,不同的生命周期
大量 Solidity 安全问题,都与“数据究竟放在哪里”有关。特别是可升级代理,storage 布局一旦错位,代码看似正确也会读写错误变量。
永久状态
写入链上状态树,交易结束后仍存在。成本高,布局顺序会影响代理升级安全。
调用期临时内存
只在当前调用期间存在,可以修改。函数结束后不会保存。
外部输入区域
包含外部调用参数,只读且通常比复制到 memory 更省 Gas。
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。
a9059cbbfunction selector000...recipientaddress 参数000...amountuint256 参数静态类型通常直接放在 32 字节槽位中。动态类型会先放偏移量,再在后面存放长度与内容。
用 Foundry 工具验证
cast sig "transfer(address,uint256)"
# 0xa9059cbb
cast calldata "transfer(address,uint256)" 0x1111111111111111111111111111111111111111 1000000000000000000事件可以与状态变化不一致。恢复调用意图要从 calldata 和代码路径开始,再用事件与余额变化相互验证。
call、delegatecall 与 staticcall
三者最大的差别不是语法,而是代码在哪个上下文执行、谁的 storage 被写入,以及是否允许修改状态。
call目标合约目标合约当前调用合约delegatecall目标合约调用方合约保留上层调用者staticcall目标合约禁止写入当前调用合约代理不是把自己的状态复制给实现。它是在代理上下文中运行实现代码,因此新逻辑可以继续读写原来的代理 storage。
失败与回滚:理解调用边界
当调用帧以 REVERT、异常或耗尽 Gas 失败时,该调用帧及其子调用造成的状态变化会被撤销。错误如果继续向上传播,整笔交易都会回到执行前状态。
上层写入 storage
调用外部合约
外部调用 revert
未捕获则全部回滚
但 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、参数类型与解码值。