2、区块链与智能合约基础:以太坊原理、账户模型、交易与Gas、智能合约生命周期
智能合约的安全审计,本质上是对“运行在以太坊虚拟机(EVM)上的不可篡改业务逻辑”进行风险排查。因此,理解以太坊的底层运行机制是审计工作的前提。本章将围绕以太坊的核心原理、账户模型、交易与Gas机制以及智能合约的生命周期展开,为后续漏洞分析建立必要的知识框架。
2.1 以太坊核心原理:从“世界计算机”到状态机
2.1.1 核心原理:基于交易的状态转换
以太坊本质上是一个基于交易的状态机(Transaction-Based State Machine)。初始状态(Genesis State)经过一系列交易的驱动,转换为新的状态。每个区块包含若干笔交易,每笔交易都会触发一次确定性的状态转换。审计人员必须理解:智能合约的每一次函数调用,都是一次状态转换的候选者,而漏洞往往就隐藏在这些状态转换的边界条件中。
| 组件 | 说明 | 审计关注点 |
|---|---|---|
| 状态(State) | 所有账户(EOA+CA)的余额、nonce、存储和代码的集合 | 状态变更是否符合业务预期 |
| 交易(Transaction) | 由EOA签名的指令,包含接收者、数据、Gas限制等 | 交易数据是否可被恶意构造 |
| 区块(Block) | 打包一组交易,包含父哈希、时间戳、难度等元数据 | 时间戳依赖、区块哈希依赖 |
| EVM | 以太坊虚拟机,执行字节码的沙盒环境 | 操作码序列、Gas消耗、栈深度 |
2.1.2 判断方法:识别状态转换的“原子性”
在审计中,判断一个合约是否安全,需要追踪每一笔交易的状态转换路径。具体操作流程如下:
- 确定入口函数:找到所有
public或external函数。 - 追踪状态变量:列出函数中所有读写的状态变量。
- 分析转换条件:检查状态转换是否依赖外部数据(如预言机、时间戳、区块哈希)。
- 检查原子性:确认状态转换是否在单笔交易内完成,是否存在跨交易的状态依赖。
2.1.3 案例拆解:状态机漏洞的典型场景
以一个简单的众筹合约为例。合约状态包括 funding(筹款中)、successful(成功)、failed(失败)。初始状态为 funding。当筹款截止时间到达且金额达标时,状态转为 successful;否则转为 failed。
漏洞场景:如果合约允许在 funding 状态下反复调用 refund(),且未在退款后更新状态,攻击者可以通过重入攻击在状态未切换前多次提取资金。审计时需检查状态转换是否在资金操作之前完成。
2.1.4 常见误区与总结
- 误区一:认为“状态变量是私有的就安全”。
private仅阻止其他合约读取,但链上数据公开可见。 - 误区二:忽略“存储”与“内存”的区别。存储是持久化的,内存是临时的,错误使用可能导致数据覆盖。
- 总结:以太坊状态机的核心是“确定性”——同一组交易在任何节点执行结果必须一致。任何破坏确定性的操作(如依赖
block.number的随机数)都是审计重点。
2.2 账户模型:EOA 与合约账户的博弈
2.2.1 核心原理:两类账户的差异
以太坊账户分为外部账户(EOA)和合约账户(CA)。EOA 由私钥控制,可以发起交易;CA 由代码控制,只能被动响应交易。审计中需要特别关注账户间交互的权限边界。
| 属性 | EOA(外部账户) | CA(合约账户) |
|---|---|---|
| 控制者 | 私钥持有者 | 合约代码逻辑 |
| 发起交易 | 可以 | 不可以(只能通过内部交易) |
| 存储 | 仅 nonce 和 balance | 代码 + 存储(Storage) |
| 审计重点 | 私钥管理、签名验证 | 权限控制、委托调用风险 |
2.2.2 判断方法:权限与身份验证
审计合约时,需要明确每个关键函数的调用者身份。操作流程:
- 使用
msg.sender判断直接调用者; - 使用
tx.origin判断交易发起者(不推荐使用,存在钓鱼风险); - 检查合约是否使用
delegatecall,这会导致调用者上下文被篡改。
tx.origin 作为身份验证机制是常见漏洞。如果合约使用 require(tx.origin == owner),攻击者可以诱导 owner 调用恶意合约,恶意合约再调用目标合约,此时 tx.origin 仍是 owner,从而绕过权限。2.2.3 案例拆解:delegatecall 的上下文陷阱
假设有一个代理合约 Proxy 和一个逻辑合约 Logic。Proxy 通过 delegatecall 将调用转发给 Logic。由于 delegatecall 在调用者的上下文中执行,Logic 中修改的存储变量实际修改的是 Proxy 的存储。
攻击场景:如果 Logic 合约的存储布局与 Proxy 不一致(例如 Proxy 的 slot 0 是 owner,而 Logic 的 slot 0 是 balance),攻击者可以通过调用 Logic 的函数修改 Proxy 的 owner。审计时必须核对存储布局的一致性。
2.2.4 常见误区与总结
- 误区:认为合约账户“不能主动做事”就安全。合约可以通过内部交易(如调用其他合约)间接发起操作。
- 总结:账户模型的核心是“上下文隔离”。审计时要时刻问:当前代码运行在谁的上下文中?存储读写的是谁的存储?
2.3 交易与Gas:成本约束下的安全边界
2.3.1 核心原理:Gas 的计量与限制
每笔交易都包含 gasLimit(用户愿意支付的最大 Gas 量)和 gasPrice(每单位 Gas 的价格)。交易费用 = gasUsed × gasPrice。EVM 中每个操作码都有固定的 Gas 消耗,其中 SSTORE(存储写入)是最昂贵的操作之一。
| 操作码 | Gas 消耗 | 说明 |
|---|---|---|
| ADD/SUB | 3 | 算术运算 |
| SLOAD | 800(热)/ 2100(冷) | 读取存储 |
| SSTORE | 20000(新增)/ 5000(修改)/ 2900(清零返还) | 写入存储 |
| CALL | 700(基础)+ 额外 | 调用其他合约 |
| SELFDESTRUCT | 5000 | 销毁合约(已弃用) |
2.3.2 判断方法:Gas 相关的漏洞模式
审计中需要关注以下 Gas 相关风险:
- Gas 耗尽(Out of Gas):函数在循环中执行大量存储操作,导致交易失败。攻击者可能利用这一点使合约功能不可用。
- Gas 价格操纵:如果合约逻辑依赖
tx.gasprice,攻击者可以通过设置不同的 gasPrice 影响合约行为。 - 区块 Gas 上限:单个区块的 Gas 总量有限(约 3000 万),如果合约操作过于昂贵,可能无法被打包。
gasleft() 检查剩余 Gas 是常见模式,但要注意 gasleft() 本身也会消耗少量 Gas。在循环中依赖 gasleft() 可能导致边界条件不一致。2.3.3 案例拆解:循环中的 Gas 耗尽攻击
考虑一个批量转账合约,用户传入一个地址数组,合约循环为每个地址转账。如果数组长度过大,循环中的 transfer 调用会消耗大量 Gas,导致交易失败。
攻击场景:攻击者构造一个包含数千个地址的数组,使合约在循环中耗尽 Gas。虽然交易失败,但攻击者可以反复发起此类交易,造成合约的拒绝服务(DoS)。审计建议:限制数组长度,或使用“拉取模式”(让用户自行提取)替代“推送模式”。
2.3.4 常见误区与总结
- 误区:认为
transfer是安全的转账方式。虽然transfer限制 Gas 为 2300,但接收方是合约时仍可能失败。 - 总结:Gas 不仅是成本问题,更是安全边界。审计时要评估每个函数的 Gas 消耗上限,确保在极端输入下不会导致不可用。
2.4 智能合约生命周期:从创建到销毁
2.4.1 核心原理:合约的创建与执行
合约的生命周期包括:创建(CREATE/CREATE2)→ 执行(调用)→ 升级(代理模式)→ 销毁(SELFDESTRUCT)。审计需要覆盖整个生命周期,而不仅仅是部署后的代码。
| 阶段 | 关键操作 | 审计要点 |
|---|---|---|
| 创建 | 构造函数、初始化参数 | 构造函数是否可被攻击者抢先调用(如 Uniswap 的 initialize 漏洞) |
| 执行 | 函数调用、内部交易 | 权限控制、重入、整数溢出 |
| 升级 | 代理合约、存储迁移 | 存储布局冲突、升级权限 |
| 销毁 | SELFDESTRUCT | 资金锁定、意外销毁 |
2.4.2 判断方法:生命周期各阶段的审计清单
- 创建阶段:检查构造函数是否设置正确的初始状态;是否使用
CREATE2且地址可预测。 - 执行阶段:检查所有
external函数的访问控制;检查fallback和receive函数的行为。 - 升级阶段:检查代理合约的
upgradeTo函数权限;检查新旧合约的存储布局是否兼容。 - 销毁阶段:检查
SELFDESTRUCT是否可被外部调用;销毁后资金去向。
SELFDESTRUCT 在以太坊合并后已弃用(EIP-6780),但在审计旧合约时仍需关注。销毁合约会将剩余 ETH 强制发送到指定地址,且无法撤销。2.4.3 案例拆解:代理合约的存储冲突
一个典型的可升级合约使用 Proxy + Implementation 模式。Proxy 通过 delegatecall 将调用转发给 Implementation。审计时发现:Proxy 的 slot 0 存储 owner,而 Implementation 的 slot 0 存储 totalSupply。
攻击场景:攻击者调用 Implementation 中的 setTotalSupply() 函数,实际修改的是 Proxy 的 owner 变量。如果 setTotalSupply 没有权限控制,攻击者可以直接篡改 owner。审计建议:使用 EIP-1967 标准存储槽位,避免与业务变量冲突。
2.4.4 常见误区与总结
- 误区:认为合约部署后代码不可变就“安全”。通过代理模式,逻辑可以升级,但升级本身可能引入新漏洞。
- 总结:生命周期审计的核心是“状态一致性”——确保合约在任何阶段的状态转换都符合预期,且不存在未授权的状态变更路径。