2、区块链与智能合约基础:以太坊虚拟机(EVM)架构、Solidity语言核心语法、合约部署与调用机制、交易与Gas机制、常见合约模式

智能合约安全审计的起点,并非直接阅读漏洞列表或扫描工具报告,而是建立在对以太坊底层执行环境的精确理解之上。审计人员必须清楚一段 Solidity 代码在 EVM 中如何被编译为字节码、如何被调度执行、状态如何变更、Gas 如何消耗,以及不同合约设计模式会引入哪些结构性风险。本章从 EVM 架构出发,依次拆解 Solidity 核心语法、合约部署与调用链路、交易与 Gas 机制,最后归纳常见合约模式及其安全含义,为后续章节的漏洞分析奠定扎实基础。

以太坊智能合约执行全景图 外部账户 (EOA) 发起交易 nonce / gasPrice / gasLimit 交易池 → 区块 矿工打包 / 排序 区块 Gas 上限 以太坊虚拟机 (EVM) 字节码 opcodes 栈 Stack 1024 深度 内存 Memory 易失性 存储 Storage 持久化 Calldata 只读输入 Gas 计量 逐指令扣费 世界状态 (World State) 账户余额 / 合约存储 / nonce Merkle Patricia Trie 根哈希 Gas 机制 • gasLimit:交易最大消耗 • gasPrice:单位 Gas 价格 • 未用完 Gas 退还 • 耗尽则回滚状态 常见合约模式 • 工厂模式 (Factory) • 代理模式 (Proxy) • 提款模式 (Withdrawal) • 守卫检查模式 (CEI) • 访问控制模式 (RBAC) 安全审计关注点 重入攻击 外部调用先于状态更新 Gas 耗尽 循环/存储操作 权限漏洞 修饰符/角色检查 整数溢出 算术运算边界 未检查返回值 底层调用失败 时间戳依赖 block.timestamp

2.1 以太坊虚拟机(EVM)架构

2.1.1 核心原理:基于栈的准图灵完备执行环境

EVM 是一个基于栈的虚拟机,运行在以太坊节点内部,负责执行智能合约的字节码。它被设计为准图灵完备——理论上可以计算任何可计算函数,但受限于 Gas 机制,无法无限循环。EVM 的架构核心包含以下组件:

  • 栈(Stack):深度为 1024 的 LIFO 栈,每个元素为 256 位(32 字节)。所有算术、逻辑、比较操作均在栈顶完成。栈操作是 EVM 中最廉价的指令之一(如 PUSH、DUP、SWAP 消耗 3 Gas)。
  • 内存(Memory):易失性字节数组,按字节寻址,仅在当前交易执行期间存在。内存扩展成本呈二次方增长(3 Gas + 每 32 字节 3 Gas),因此大量使用内存会导致 Gas 飙升。
  • 存储(Storage):持久化的键值存储,以 256 位键映射到 256 位值。存储写入是 EVM 中最昂贵的操作——首次写入一个非零值消耗 20,000 Gas,修改已存在的非零值消耗 5,000 Gas,清零操作返还 15,000 Gas(Gas 退款机制)。
  • Calldata:交易附带的输入数据,只读,不可修改。函数参数、msg.data 均来自此处。
  • 程序计数器(PC):指向当前执行的字节码位置。
  • Gas 计量器:每条指令都有固定的 Gas 成本,复杂操作(如 SHA3、SSTORE、CREATE)成本更高。

审计视角:EVM 的存储与内存分离设计是安全审计的关键。存储操作的高成本意味着攻击者可能通过大量存储写入来耗尽合约的 Gas 预算;而内存扩展的二次方成本则可能导致某些算法在特定输入下 Gas 消耗异常。审计时应关注合约是否在循环中执行存储写入或内存扩展操作。

2.1.2 判断方法:识别 EVM 版本与操作码差异

EVM 经历了多个版本升级(Homestead、Byzantium、Constantinople、Istanbul、Berlin、London、Shanghai、Cancun)。不同版本引入了新的操作码或修改了 Gas 成本。审计时需确认目标合约部署的链与 EVM 版本,重点关注:

  • Berlin 升级(2021):增加了 ACCESS_LIST 相关操作码,降低了特定地址访问的 Gas 成本。
  • London 升级(2021):引入 EIP-1559 费用市场改革,BASEFEE 操作码出现。
  • Shanghai 升级(2023):引入 PUSH0 操作码(EIP-3855),将 0 压入栈仅需 2 Gas。
  • Cancun 升级(2024):引入 TSTORE/TLOAD 临时存储操作码(EIP-1153),以及 blob 交易类型。

审计时可通过 opcode 反汇编工具(如 evm-disasm、panoramix)检查合约字节码中是否包含特定版本的操作码,从而判断合约的兼容性风险。

2.1.3 案例拆解:存储碰撞与 Gas 退款滥用

假设某合约使用 mapping(address => uint256) 记录用户余额,并在提款函数中执行 balance[msg.sender] = 0 后再转账。攻击者可能构造一个场景:

// 攻击者先存入 1 wei,再调用提款
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");
    balances[msg.sender] = 0;  // 存储写入:5,000 Gas
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}

如果攻击者在同一交易中反复调用 withdraw()(通过恶意合约的 fallback 函数),每次调用都会执行一次存储清零操作。由于清零操作会触发 Gas 退款(15,000 Gas),攻击者可能利用退款机制在单笔交易中执行大量操作,导致交易 Gas 上限被突破。虽然 EIP-2200 已调整退款上限(不超过交易 Gas 消耗的一半),但审计时仍需关注存储清零与重入的组合风险。

2.1.4 常见误区与总结复盘

  • 误区 1:认为 EVM 与 JVM 类似,忽略存储与内存的成本差异。
  • 误区 2:忽视操作码版本差异,导致对 Gas 成本的误判。
  • 误区 3:将 EVM 的 256 位字长视为“无限精度”,忽略整数溢出风险。

复盘要点:EVM 架构决定了智能合约的安全边界。审计时需始终问三个问题:数据存储在哪里(存储/内存/栈)?操作成本是多少(Gas 计量)?状态何时变更(执行顺序)?

2.2 Solidity 语言核心语法

2.2.1 核心原理:类型系统、可见性与数据位置

Solidity 是面向合约的高级语言,编译为 EVM 字节码。其核心语法特征直接影响安全:

  • 数据位置(Data Location):storage(持久化)、memory(临时)、calldata(只读)。错误的数据位置声明可能导致意外的存储引用或昂贵的拷贝操作。
  • 可见性修饰符:public(外部可调用)、private(仅合约内部)、internal(继承合约可调用)、external(仅外部调用)。public 函数会自动生成 getter,但暴露内部状态。
  • 函数修饰符(Modifier):如 onlyOwner、nonReentrant。修饰符的执行顺序和短路逻辑可能引入漏洞。
  • 错误处理:require(条件不满足则回滚并退还剩余 Gas)、assert(内部错误,消耗所有 Gas)、revert(自定义错误信息)。
  • 事件(Event):记录在交易日志中,不占用存储,但可用于审计追踪。

2.2.2 判断方法:识别危险语法模式

审计时需重点扫描以下 Solidity 语法模式:

语法模式 风险描述 审计建议
tx.origin 用于身份验证时,可能被钓鱼攻击利用 改用 msg.sender
block.timestamp 矿工可操纵时间戳(±15 秒) 避免用于随机数或关键逻辑
selfdestruct 强制发送 ETH 到指定地址,可能破坏合约逻辑 评估是否必要,限制调用权限
delegatecall 在调用合约的上下文中执行代码,存储共享 严格校验目标地址,避免存储布局冲突
abi.encodePacked 可能产生哈希碰撞 使用 abi.encode 或明确分隔符

2.2.3 案例拆解:数据位置误用导致的存储覆盖

考虑以下合约片段:

contract StorageCollision {
    uint256[] public data;
    
    function update(uint256[] memory newData) external {
        uint256[] storage localRef = newData;  // 错误:memory 赋值给 storage 引用
        data = localRef;
    }
}

在 Solidity 0.8.x 中,memory 数组不能直接赋值给 storage 引用,编译器会报错。但在 0.7.x 及更早版本中,这种写法可能导致 localRef 指向 data 的存储位置,后续对 localRef 的修改会直接覆盖 data 的内容。审计时需检查合约是否使用了过时的 Solidity 版本,并关注数据位置的显式声明。

2.2.4 常见误区与总结复盘

  • 误区 1:认为 private 数据不可见——实际上链上数据均可公开读取。
  • 误区 2:混淆 require 与 assert 的 Gas 行为——assert 失败会消耗全部 Gas。
  • 误区 3:忽略修饰符的执行顺序——多个修饰符按声明顺序执行,先执行的修饰符可能改变状态。

2.3 合约部署与调用机制

2.3.1 核心原理:CREATE 与 CREATE2 操作码

合约部署通过 CREATE 或 CREATE2 操作码完成。两者区别在于新合约地址的计算方式:

  • CREATE:地址 = keccak256(rlp([sender, nonce]))[12:]。地址取决于发送者地址和其 nonce,无法预先确定。
  • CREATE2:地址 = keccak256(0xff ++ sender ++ salt ++ keccak256(init_code))[12:]。地址由 salt 和初始化代码决定,可预先计算。

合约调用分为两种类型:

  • 消息调用(CALL):创建新的执行上下文,msg.sender 为调用者,msg.value 可携带 ETH。
  • 委托调用(DELEGATECALL):在调用者的存储上下文中执行目标合约代码,msg.sender 保持不变,msg.value 为当前交易的值。这是代理模式(Proxy)的基础。

2.3.2 操作流程:合约部署与调用的完整链路

  1. 部署阶段:EOA 发送一笔 to = 空 的交易,data 字段包含合约字节码和构造函数参数。矿工执行 CREATE 操作码,返回新合约地址。
  2. 构造函数执行:构造函数代码在部署时执行,其状态变更(如设置 owner)会持久化到新合约的存储中。
  3. 调用阶段:EOA 或合约发送交易,to 为目标合约地址,data 为函数选择器(前 4 字节)和 ABI 编码参数。
  4. 函数选择器分发:EVM 根据 calldata 前 4 字节匹配函数签名,跳转到对应代码段执行。
  5. 返回值处理:函数返回值通过内存传递,调用方需按 ABI 解码。

2.3.3 案例拆解:代理合约的存储布局冲突

代理模式(Proxy)将逻辑合约与存储合约分离,通过 delegatecall 将调用转发到逻辑合约。但若逻辑合约的存储变量布局与代理合约不一致,可能导致存储覆盖。

// 代理合约
contract Proxy {
    address public implementation;
    address public owner;
    
    function upgrade(address newImpl) external {
        require(msg.sender == owner, "Not owner");
        implementation = newImpl;
    }
    
    fallback() external payable {
        (bool ok, ) = implementation.delegatecall(msg.data);
        require(ok, "Delegatecall failed");
    }
}

// 逻辑合约(存储布局必须与代理一致)
contract LogicV1 {
    address public implementation;  // slot 0
    address public owner;           // slot 1
    uint256 public value;           // slot 2
    
    function setValue(uint256 _v) external {
        value = _v;
    }
}

如果升级到 LogicV2 时新增了存储变量,必须确保新变量追加在原有变量之后,否则会覆盖代理合约的 implementation 或 owner 字段,导致权限被篡改。审计时需对比代理合约与逻辑合约的存储布局。

2.3.4 常见误区与总结复盘

  • 误区 1:认为 delegatecall 与 call 等价——前者共享存储,后者隔离存储。
  • 误区 2:忽略构造函数中的 msg.sender——部署者地址在构造函数中可能被记录为 owner,但后续升级时需重新验证。
  • 误区 3:未检查 call 的返回值——底层调用失败时不会自动回滚,需手动 require。

2.4 交易与 Gas 机制

2.4.1 核心原理:EIP-1559 费用市场

以太坊 London 升级后,交易费用采用 EIP-1559 机制:

  • Base Fee(基础费):由网络根据区块利用率动态调整,必须支付,销毁。
  • Priority Fee(小费):用户自愿支付给矿工,激励打包。
  • Max Fee(最大费):用户愿意支付的每单位 Gas 上限,maxFeePerGas = baseFee + priorityFee。

交易的总 Gas 消耗 = gasUsed × (baseFee + priorityFee)。未使用的 Gas 会退还,但 baseFee 部分不退还。

2.4.2 判断方法:Gas 相关漏洞识别

漏洞类型 触发条件 影响
Gas 耗尽(Out of Gas) 循环次数过多、存储写入密集 交易回滚,状态不变
Gas 退款滥用 存储清零、自毁操作 攻击者利用退款在单笔交易中执行更多操作
Gas 价格操纵 依赖 tx.gasprice 做决策 矿工可操纵交易费用影响合约逻辑
区块 Gas 上限 合约操作超过 30M Gas 交易无法被打包

2.4.3 案例拆解:循环中的 Gas 耗尽攻击

某空投合约允许用户批量领取奖励:

function claimBatch(address[] calldata recipients) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        _claim(recipients[i]);  // 每次调用执行存储写入
    }
}

如果 recipients 数组长度过大(例如 10,000 个地址),每次 _claim 执行一次存储写入(20,000 Gas),总消耗将超过区块 Gas 上限(30M),导致交易永远无法成功。攻击者可以构造超长数组使合约功能瘫痪。审计时需关注循环中的存储操作,并建议设置数组长度上限或分批处理。

2.4.4 常见误区与总结复盘

  • 误区 1:认为 Gas 耗尽只是性能问题——它可能被用作 DoS 攻击手段。
  • 误区 2:忽略 baseFee 的销毁机制——合约无法回收这部分费用。
  • 误区 3:在合约中依赖 block.gaslimit 或 tx.gasprice 做业务判断——这些值由矿工控制。

2.5 常见合约模式

2.5.1 核心原理:模式的安全含义

智能合约领域沉淀了若干经典设计模式,每种模式都有其安全权衡:

  • 工厂模式(Factory):通过工厂合约批量创建子合约。风险在于子合约的初始化参数验证、创建失败的处理。
  • 代理模式(Proxy):逻辑与存储分离,支持升级。风险在于存储布局冲突、函数选择器碰撞。
  • 提款模式(Withdrawal):用户主动提取资金,而非合约推送。降低重入风险,但需处理提款延迟。
  • 守卫检查模式(CEI):先检查(Checks)、再执行(Effects)、后交互(Interactions)。这是防止重入攻击的基础模式。
  • 访问控制模式(RBAC):基于角色的权限管理。风险在于角色分配错误、权限提升。

2.5.2 对比分析:不同模式的安全特性

模式 优点 风险点 审计重点
工厂模式 批量部署、代码复用 子合约初始化参数被篡改 检查 salt 与初始化参数
代理模式 可升级、节省 Gas 存储布局冲突、选择器碰撞 对比存储布局、检查 fallback
提款模式 降低重入风险 用户忘记提款、资金锁定 检查提款条件与超时机制
CEI 模式 防止重入 状态更新顺序错误 验证所有外部调用前状态已更新
RBAC 模式 权限清晰