第四章 智能合约安全威胁模型与攻击面分析
智能合约安全审计的本质,是对合约在不可信环境中运行时所有可能被利用的路径进行系统性排查。与传统的Web应用安全不同,区块链上的合约一旦部署便难以篡改,且所有交互都发生在公开、透明的账本之上。因此,审计师必须首先建立一套完整的威胁模型,明确信任边界在哪里、哪些输入是不可信的、状态如何被外部影响,以及各类漏洞的底层触发机制。本章将围绕信任边界、外部调用风险、权限模型、状态变量可见性以及常见漏洞分类展开,帮助读者构建从“看代码”到“看攻击面”的思维跃迁。
4.1 核心原理:信任边界与攻击面定义
在Solidity中,信任边界是指合约内部状态与外部不可信实体之间的逻辑分界线。以太坊上的所有外部账户(EOA)和合约账户(CA)都可以向目标合约发起调用,而合约本身无法区分调用者是“普通用户”还是“恶意攻击者”。因此,任何来自外部的数据——包括函数参数、msg.sender、tx.origin、msg.value、外部合约的返回值——都必须被视为不可信输入。
攻击面则是这些不可信输入能够触达的所有代码路径和状态变更的组合。一个典型的攻击面包括:
- 公开函数入口:所有未标记为 internal/private 的函数,均可被外部直接调用。
- 回调机制:通过 transfer/send/call 触发接收方合约的 fallback 或 receive 函数,形成二次入口。
- 存储依赖:合约读取的存储变量,若可被外部间接修改(如通过代理合约的升级函数),则扩大了攻击面。
- 跨合约调用:调用其他合约的函数时,该合约的返回值或执行逻辑可能引入新的不可信行为。
4.2 外部调用风险:不只是重入
外部调用是Solidity中最危险的操作之一,因为它将控制权移交给另一个地址,而该地址的代码行为完全不受当前合约控制。常见的外部调用方式包括 address.transfer()、address.send()、address.call{value: x}("") 以及调用其他合约的公开函数。
外部调用的风险不仅限于重入攻击,还包括:
- 重入攻击:被调用的合约在回调中再次进入当前合约的未保护函数,导致状态不一致。
- Gas 耗尽:被调用的合约消耗过多 Gas,导致主交易回滚,造成拒绝服务。
- 返回值未检查:
call返回布尔值,若忽略失败,可能导致逻辑继续执行,产生错误状态。 - 恶意合约伪装:调用一个地址,该地址可能是攻击者部署的恶意合约,返回伪造数据。
判断外部调用风险的操作流程如下:
- 第一步:定位所有外部调用点,标记调用方式(call/send/transfer/直接调用)。
- 第二步:检查调用后是否有状态更新,若有,确认是否遵循“先更新状态,再外部调用”的检查-效果-交互模式。
- 第三步:检查调用返回值是否被处理,特别是
call的布尔返回值。 - 第四步:评估被调用合约是否可信,若不可信,考虑使用白名单或隔离机制。
transfer 的 2300 Gas 限制来防止重入。自 EIP-1884 后,部分操作码 Gas 成本上升,且 Solidity 0.8.x 中 transfer 的 Gas 转发行为可能变化,最佳实践是使用“检查-效果-交互”模式 + 重入锁。
4.3 权限模型:最小特权原则的落地
权限模型定义了谁可以执行哪些敏感操作。常见的权限模型包括:单一 Owner(OpenZeppelin Ownable)、基于角色的访问控制(RBAC)、以及基于时间锁的多签治理。审计时需要关注的核心问题包括:
- 权限初始化:构造函数或初始化函数中是否设置了正确的初始权限?是否可能被他人抢先调用?
- 权限转移:transferOwnership 是否包含两步确认?是否可能被零地址或恶意地址接管?
- 修饰符校验:modifier 中是否使用了
msg.sender而非tx.origin?是否在函数所有分支上都生效? - 提权路径:是否存在普通用户可以调用仅管理员权限的函数?例如,某个函数缺少 onlyOwner 修饰符。
一个实用的权限审计清单如下:
| 检查项 | 风险等级 | 说明 |
|---|---|---|
| 构造函数中是否设置 owner | 高 | 若未设置,合约可能无主,或可被任意初始化 |
| 是否使用 tx.origin 做鉴权 | 高 | tx.origin 可被钓鱼攻击利用,应使用 msg.sender |
| 敏感函数是否都有权限修饰符 | 高 | 遗漏一个即可导致权限绕过 |
| 权限转移是否两步确认 | 中 | 防止误操作或恶意抢注 |
| 角色是否可枚举 | 低 | 便于审计,但非安全必需 |
4.4 状态变量可见性:公开的“秘密”
Solidity 中,public 状态变量会自动生成 getter 函数,但这并不意味着 private 变量是安全的。区块链上的所有存储都是公开可读的,任何 private 变量都可以通过读取合约存储槽位(如使用 web3.eth.getStorageAt)来获取。因此,可见性修饰符只影响合约内部函数的访问权限,不影响链上数据的公开性。
审计时需要关注:
- 敏感数据是否上链:如私钥、密码、随机数种子等,不应存储在链上。
- 存储布局是否可预测:在代理合约模式中,实现合约的存储变量布局必须与代理合约一致,否则会导致存储冲突。
- public 变量是否泄露内部逻辑:某些 public 映射或数组可能暴露用户余额、权限状态等,攻击者可据此分析攻击路径。
4.5 常见漏洞分类与案例拆解
根据 SWC Registry 和历年审计报告,智能合约漏洞可归纳为以下几大类:
| 漏洞类型 | 触发条件 | 典型后果 | 审计方法 |
|---|---|---|---|
| 重入 | 外部调用后未更新状态 | 资金被重复提取 | 检查-效果-交互模式、重入锁 |
| 整数溢出/下溢 | 算术运算超出类型范围 | 余额异常、权限绕过 | Solidity 0.8+ 自动检查,或 SafeMath |
| 访问控制缺失 | 敏感函数无权限校验 | 任意用户提款、销毁 | 权限矩阵审查 |
| 拒绝服务 | 外部调用失败导致回滚 | 合约功能不可用 | try/catch、隔离外部调用 |
| 前端运行 | 交易在内存池可见 | 攻击者抢先交易获利 | commit-reveal、滑点控制 |
| 逻辑错误 | 业务条件判断错误 | 状态不一致、资金损失 | 边界值测试、形式化验证 |
案例拆解:DAO 重入攻击(2016)
背景:The DAO 合约允许用户通过 splitDAO 函数提取以太币。该函数在发送 ETH 之后才更新用户的余额映射,且使用 call.value()() 发送资金。
攻击过程:攻击者创建了一个恶意合约,其 fallback 函数中再次调用 splitDAO。由于原合约尚未更新余额,攻击者可以重复提取资金,直到 DAO 合约的 ETH 被掏空。
关键代码模式(简化):
function splitDAO(uint _proposalID, address _newCurator) public {
uint balance = balances[msg.sender];
// 先转账
msg.sender.call.value(balance)();
// 后更新状态(漏洞所在)
balances[msg.sender] = 0;
}
修复方案:将状态更新移到转账之前,或使用重入锁。该事件直接导致了以太坊硬分叉,也催生了“检查-效果-交互”模式成为行业标准。
复盘经验:审计时,任何涉及资金转移的函数,必须严格遵循“先更新内部状态,再执行外部调用”的顺序。同时,对 call 的返回值必须检查,并考虑使用 transfer 或 send 的 Gas 限制作为辅助防线。
4.6 常见误区与总结复盘
在审计实践中,初学者常犯以下错误:
- 只关注重入,忽略其他外部调用风险:例如,未检查
call的返回值,导致合约在失败后继续执行。 - 认为 private 变量是安全的:链上数据无隐私,private 仅限制合约内访问。
- 权限模型只检查 owner:忽略了多签、角色继承、以及初始化函数的权限漏洞。
- 忽略存储布局冲突:在代理合约升级时,新增变量插入中间位置导致数据错乱。
总结:威胁模型是审计的起点,攻击面分析是审计的骨架。审计师应始终带着“如果我是攻击者,我会如何利用这个输入?”的思维去审查每一行代码。将信任边界、外部调用、权限和可见性四个维度交叉分析,才能构建完整的攻击面视图,从而有效识别和防范各类漏洞。