一致,不等于正确
共识回答“大家是否接受同一状态”;应用安全回答“这个状态是否满足业务不变量”。两者不能互相替代。
第二十章 · 第二课 / APPLICATION SECURITY
每个节点都可以一模一样地执行一段有漏洞的逻辑。 共识保证大家得到同一个结果,却不保证这个结果符合应用原本的意图。
关键:协议正确执行了错误的应用规则。区块链的确定性会复制漏洞的后果,也会复制修复后的约束。
共识回答“大家是否接受同一状态”;应用安全回答“这个状态是否满足业务不变量”。两者不能互相替代。
代码、管理员、价格数据、实现合约与外部依赖共同组成信任图。最弱的一条边可能决定全部资金的安全。
规格、不变量、测试、审计、时间锁、监控与应急响应是一条连续防线;任何单点都不是“绝对安全证明”。
Orientation
目录 PDF 对本课的正式定义是“应用层风险:重入攻击、权限配置、预言机与升级风险”。四类问题看似不同,实质都发生在协议规则之上的应用信任边界。
假设一笔攻击交易有合法签名、支付了足够 Gas,被验证者收入区块,EVM 逐条执行 opcode, 最终所有节点算出同一个状态根。以太坊有没有“出错”?没有。若合约允许攻击者在余额尚未清零时重复提款, 网络只是忠实执行了它收到的程序。
这正是应用层安全最反直觉的地方:确定性不会判断意图。协议可以保证交易排序、执行规则与状态转换 对所有节点一致,却不知道开发者本来想让“每人只能提款一次”,也不知道某个价格源刚刚被操纵。智能合约控制真实资产, 交互通常不可逆,部署后的代码又默认难以修改,所以应用需要比普通后端更明确的安全规格。 [1]
Safety property
例如总负债不能超过允许上限、没有 MINTER_ROLE 的账户不能铸币、同一债权不能被支付两次。
Liveness property
例如诚实用户最终能提款、价格源故障后系统可以恢复、治理不会因为所有密钥丢失而永久锁死。
Invariant
不变量是可检查的系统承诺。它比“函数看起来没问题”更接近真正的安全规格。
Threat model
攻击者可借到巨额瞬时流动性、部署恶意合约、组合任意公开调用,并观察待处理交易。
One model, four entrances
重入利用控制流,权限利用控制权,预言机利用输入事实,升级利用代码可变性。把入口分开,才能为每类风险设计对应防线。
攻击者不必“破解以太坊”。公开网络给了所有人相同接口:部署合约、发起嵌套调用、借用原子流动性、 读取状态、组合多个协议。安全设计必须假定调用者会选择最不利的输入与顺序,而不是按产品界面的正常路径操作。 Solidity 官方安全说明因此把每次外部合约交互都视为潜在危险,并建议从控制流、调用失败、授权与资源边界一起考虑。 [2]
Reentrancy failure
系统把外部调用当成普通函数返回,却忘了对方可以在返回前重新进入自己或关联合约。
Authority failure
一个账户拥有不必要的能力,或者权限转移、撤销与延迟没有形成完整流程。
Information failure
合约逻辑完全正确,却对被操纵、陈旧、单位错误或暂时不可用的数据作出正确反应。
Change failure
代理允许补丁和迭代,也让升级授权、初始化和存储布局成为新的关键安全面。
Reentrancy
重入不是两笔交易同时执行,而是同一笔交易的调用栈中,外部合约在原函数完成前回调。危险来自“可观察的中间状态”。
EVM 调用是同步的。合约 A 执行 call、调用代币钩子或另一个未知合约时,会暂停自己的当前函数,
把剩余 Gas 与控制权交给 B。B 可以返回,也可以先调用 A 的另一个入口。只要整笔交易尚未结束,嵌套调用都共享同一份可变化的链上状态。
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
// 先交出控制权
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
// 回来以后才清零:太晚
balances[msg.sender] = 0;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
// 先提交本地状态
balances[msg.sender] = 0;
// 最后才交出控制权
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
右侧采用 Checks-Effects-Interactions:先检查前置条件,再写入本地效果,最后与外部系统交互。 当攻击者回调时,余额已经是 0,第二次提款会失败。Solidity 文档明确提醒,重入并不只来自 ETH 转账, 任何对其他合约的调用都可能交出控制权,多合约场景还会让依赖状态在背后发生变化。 [2]
Interactive lab 01
逐步推进调用栈。模型故意保留漏洞顺序,用来观察“旧余额”如何在嵌套调用中被重复消费。
Single-function
withdraw() 在完成前再次调用 withdraw(),是最经典也最容易画出的形态。
Cross-function
外部调用期间进入另一个共享状态函数,例如一边提款,一边通过 transfer() 消耗同一余额。
Cross-contract
多个模块共享会计或价格假设;A 正在更新时,回调经 B 读取或改变了尚未一致的系统状态。
Read-only
回调没有重复写入原合约,却读取到暂时失真的汇率或余额,其他协议再依据这个视图作出有价值的决定。
ReentrancyGuard 可阻止受保护入口在活动调用期间再次进入,但不能修复错误会计或未保护的旁路。
OpenZeppelin 的工具库提供 ReentrancyGuard 与 Pausable 等构件;
它们是经过维护的基础模块,不会自动替你选择正确的函数边界、角色或不变量。
[3]
Access control
访问控制把主体映射到能力。配置错误既可能让陌生人进入,也可能让合法管理员拥有超出职责的爆炸半径。
Solidity 的 public 与 external 描述函数怎样被调用,不描述谁被授权。
private 只限制其他合约从源码接口直接调用,不会把链上数据变成秘密。真正的授权必须在每个敏感入口明确检查调用者、角色与条件。
Ownable
适合单一管理员的简单系统。若 owner 能完成所有高危操作,它也成为最集中的失败点。
Role-based access
铸币者、暂停者、预言机管理员、升级管理员可以分离,贯彻最小权限原则。
Multisig
降低单点失陷概率,但阈值签名完成后仍可能立即执行错误或恶意操作。
Timelock
为公开监控、取消与用户退出提供时间;代价是紧急修复也会变慢,因此常与窄权限暂停角色配合。
Interactive lab 02
切换四种教学配置。风险分数仅表示相对趋势,不是任何真实系统的安全评级。
OpenZeppelin 将“每个组件只能做其工作所需之事”概括为最小权限原则,并特别提醒
DEFAULT_ADMIN_ROLE 默认既管理其他角色又管理自己,因此风险很高。时间锁让维护操作先排队,
用户有机会审查并退出,但角色不可用也可能让系统永久卡住。
[4]
Oracles
EVM 不能直接请求网页或交易所 API。预言机把外部事实变成链上状态;从那一刻起,数据质量就进入应用的安全边界。
区块链节点必须对同一输入算出同一结果。如果合约在执行中各自访问互联网,不同节点可能收到不同价格、超时或响应, 共识就无法确定。因此,外部数据必须先由某个机制观察、验证、聚合,再通过交易写到链上供合约读取。
Ethereum.org 将预言机问题拆成正确性、可用性和激励相容性:数据是否来自正确来源且未被篡改,是否及时可获得, 报告者是否承担说谎成本。多节点、多来源与经济激励能降低单点风险,但不会让任何数据天然成为“绝对真相”。 [5]
闪电流动性让攻击者无需长期资本,就能在一笔原子交易中借入大量资产、推动薄池现货价、让受害协议按扭曲价格放贷, 最后偿还借款。若受害协议读取的是可被单笔交易操纵的价格,根因是价格源与风险窗口不匹配; 闪电贷只是把达到操纵规模的门槛压低。
Interactive lab 03
教学算例:10 ETH 抵押物,正常价 $2,000,最高借款率 75%。忽略手续费、滑点与清算细节。
Upgradeability
代理模式把地址与状态留在 Proxy,把业务逻辑放在 Implementation。升级不是改写旧字节码,而是让代理以后 delegatecall 到另一个实现。
普通合约部署后,其地址上的运行时代码不会像服务器文件那样被覆盖。可升级系统通过间接层实现变化:
用户始终调用代理地址;代理读取实现地址,再用 delegatecall 执行实现代码。
代码来自实现,msg.sender、余额与存储上下文却属于代理。
EIP-1967 解决的是“代理元数据放在哪里、怎样被工具发现”的标准化问题,不替应用保证谁有权升级或新逻辑是否安全。 标准建议实现或管理员槽变化时发出事件,因为持续监控这些变化本身就是安全要求。 [8]
Interactive lab 04
代理的槽位不会跟着源码变量名移动。切换升级方式,观察 V2 怎样解释同一组旧字节。
Proxy storage interpreted by V2
Outcome
V1 的 owner、balances、totalDebt 保持原槽位;V2 把 reserveFactor 追加到 slot 3。旧状态仍按原含义读取。
_authorizeUpgrade、过宽角色或管理员密钥失陷,可以把全部逻辑换成恶意实现。OpenZeppelin 的代理说明将状态与逻辑分离,并强调升级前后必须保持存储布局兼容; 可升级合约还需要用只执行一次的 initializer 代替普通构造器逻辑。 [6] [7] UUPS 通过实现合约中的升级逻辑和授权决定谁能更换实现,EIP-1822 则定义了可兼容实现的接口思路。 [9]
Technical control
使用成熟工具检测升级兼容;部署即初始化;锁定实现合约的初始化入口;为每次迁移写状态断言。
Authorization control
升级权独立于日常参数权;UUPS 的授权函数必须明确受控;定期核对实际持有人。
Temporal control
实现地址、代码哈希与升级 calldata 在执行前可见;高风险变化留出评审与退出窗口。
Structural control
EIP-1967 隔离代理元数据;ERC-7201 规范命名空间存储布局,帮助模块化系统减少槽位冲突。
ERC-7201 规定了命名空间存储布局的注释与槽位推导方式,让多个模块可以各自拥有不与常规存储树碰撞的区域。 它提升可分析性,但编译器不会仅凭注释替你强制正确实现,开发者仍需负责布局一致。 [10]
Composability
可组合性让协议像积木一样复用流动性、价格、代币和治理,也让一个错误沿着调用、资产与经济依赖传播。
现代 DApp 很少只是一份合约。借贷市场可能依赖多个代币、价格源、DEX、利率模型、清算机器人、 代理管理员和前端路由。每个组件都有自己的代码与治理边界,系统安全因此是一张图,而不是一份审计报告。
Root cause
例如读取可操纵现货价、外部调用前未扣余额、升级授权未保护。修复必须落到这里。
Amplifier
闪电流动性、高借款上限、集中权限、深度组合与缺少速率限制会扩大一次失误。
Propagation
错误价格触发清算,清算砸穿流动性,价格进一步下跌,另一个抵押协议继续连锁清算。
Impact
坏账、资产冻结、治理停摆、错误铸币、偿付顺序不公和信任损失应分别衡量。
Defense in depth
安全不是在上线前“找完所有 bug”,而是从规格到响应持续减少漏洞概率、缩小爆炸半径并提升发现与恢复速度。
Unit & integration
验证边界值、错误分支、依赖回调与升级迁移;测试应包含恶意合约,不只正常用户。
Fuzz & invariant
让工具生成输入与调用顺序,持续检查总量、偿付、权限和会计不变量。
Static analysis
寻找未检查调用、危险授权、重入面、影子变量与已知模式;快速但会有误报和漏报。
Formal verification
把目标性质与程序模型交给求解器;证明只覆盖写进规格的性质与工具实际建模的边界。
Independent audit
架构、业务逻辑与实现共同评审;范围、版本和已知限制必须公开。
Bug bounty & monitoring
为披露提供激励;监控角色、实现、价格、资金流与异常事件,并准备可演练的响应手册。
Solidity 的 SMTChecker 可对断言、状态变量与外部调用模型进行形式化分析;它将外部调用视为未知代码, 正体现了安全设计不应假定被调用方一定温顺。 [12] 源码验证则帮助使用者确认公开源码与链上字节码对应,但“代码已验证”只证明对应关系,不证明逻辑安全。 [11]
Field guide
普通使用者不必读完全部 Solidity 才能提出好问题;开发者也不应从代码细节开始,而应先画资产、权限、数据、变化与失败路径。
Misread 01
不可变减少升级攻击面,也让已部署漏洞难以修复。关键是系统承诺、复杂度与恢复需求怎样取舍。
Misread 02
还要看阈值、成员独立性、权限范围、签名流程、时间锁与能否单独替换成员。
Misread 03
上链使记录公开且确定,不保证生成价格的市场、聚合或更新时间可靠。
Misread 04
保护范围可能不完整,跨合约只读状态仍可能失真;互斥锁也不会修复错误业务不变量。
Misread 05
只代表给定版本和范围内没有被报告;不代表未来升级、经济环境和依赖始终安全。
Misread 06
若告警太慢、权限被盗、暂停覆盖错误或连偿还也冻结,开关本身可能成为新的风险。
Recap
遇到复杂协议时,先不背漏洞名字。问清控制权、授权者、事实来源和代码变化者,安全边界就会显形。
Self check
每题选择一个最准确的答案。关闭 JavaScript 或打印页面时,完整解析仍会直接显示。
答案:协议正确执行了错误的应用规则。共识保证一致,不替应用判断业务意图。
答案:交出控制权前先提交关键状态。回调读取到的是已清零或已扣减后的状态。
答案:攻破管理权的门槛。角色拆分缩小权限范围,时间锁才主要增加反应窗口。
答案:错误或陈旧数据也能被确定地记录。数据生成与调用方校验都属于信任边界。
答案:旧状态被按新的存储布局错读。布局兼容是代理升级的核心约束之一。
答案:不变量、测试、限权、监控与响应共同工作。每一层都假定其他层可能漏掉问题。
Glossary
先记每个词解决的具体问题,再去读实现细节。
Primary sources
以下均为 Ethereum、Solidity、OpenZeppelin 或 EIP 官方原文。课程正文做了教学抽象,生产实现应回到目标版本的最新文档与代码。
安全开发总览:访问控制、测试、审计、恢复、重入与预言机操纵。
重入、CEI、外部调用、授权与 Solidity 级安全注意事项。
ReentrancyGuard、Pausable 与低层调用等维护中的合约构件。
Ownable、角色、最小权限、AccessManager 与时间锁设计。
链外数据为何需要预言机,以及正确性、可用性与激励问题。
delegatecall、代理状态、实现逻辑、存储冲突与构造器限制。
initializer、继承初始化、存储布局和升级实现的开发约束。
实现、Beacon 与管理员的标准槽位,以及升级事件与监控建议。
UUPS 兼容接口、实现指针与升级机制的标准思路。
命名空间存储的注释与位置推导,服务模块化与升级场景。
如何证明公开源码与链上字节码对应,以及验证为何重要。
用形式化方法检查断言与状态性质,并建模未知外部调用。
Chapter 20 · Lesson 02 complete