严重权限控制静态扫描有 5 条规则
访问控制缺失
Missing / Broken Access Control
特权函数忘了加权限判断,或者权限判断写错(比如用 tx.origin、只校验参数不校验调用者),任何人都能执行本该只有管理员能做的事。
根因
Solidity 里 函数默认是 public 的,而权限必须显式声明。更隐蔽的情况是:权限判断写了但写错了——最常见的三种是漏掉 modifier、初始化函数可被重复调用、以及权限字段本身可被改写。
攻击是怎么一步步发生的
- 1部署者
把库合约部署上链,准备稍后初始化自己的钱包
- 2攻击者
监控内存池,发现新部署的库合约,抢先调用 initWallet(attacker)
- 3库合约
owner 被设为攻击者,且没有「已初始化」标记,无法补救
- 4攻击者
调用 execute / selfdestruct 等函数,摧毁库合约
- 5所有依赖方
依赖该库的钱包因 delegatecall 目标被毁而永久无法提款
有漏洞的版本
不要照抄soliditySolidity22 行
contract WalletLibrary {
address public owner;
uint256 public constant DAILY_LIMIT = 1 ether;
// 公开的初始化函数:部署后任何人抢着调用一次,就成了 owner
// (真实案例:Parity 钱包库合约被抢注 owner,随后被 selfdestruct,
// 导致依赖它的所有钱包永久冻结)
function initWallet(address newOwner) external {
owner = newOwner;
}
function execute(address to, uint256 value) external {
require(msg.sender == owner, "not owner");
(bool ok, ) = to.call{value: value}("");
require(ok, "call failed");
}
// 关键操作没有任何权限检查
function setDailyLimit(uint256) external {
// 空实现也算漏洞:它未来可能被填上真的逻辑
}
}initWallet没有防重复调用:部署 → 抢注 → owner 变成攻击者- 库合约(library contract)本身也可能被直接调用,这是 Parity 事件的关键
- 没有权限检查的函数即使当前是空实现,也应视为高危——它会在升级中被赋予真实逻辑
修复版本
推荐写法soliditySolidity30 行
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract SafeWallet is Initializable, Ownable {
uint256 public dailyLimit;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// 让实现合约自身无法被初始化,
// 从根上防住「直接调用实现合约抢 owner」这类攻击
_disableInitializers();
}
// initializer 修饰符保证整个生命周期只能成功执行一次
function initialize(address newOwner) external initializer {
require(newOwner != address(0), "zero owner");
_transferOwnership(newOwner);
}
function setDailyLimit(uint256 limit) external onlyOwner {
require(limit > 0 && limit <= 100 ether, "bad limit");
dailyLimit = limit;
}
function execute(address to, uint256 value) external onlyOwner {
require(value <= dailyLimit, "over limit");
(bool ok, ) = to.call{value: value}("");
require(ok, "call failed");
}
}- 用
Ownable(或 AccessControl)而不是自己写require(msg.sender == owner) initializer修饰符 +_disableInitializers()是两个必须一起用的东西- 每个改状态的 external 函数都要有权限修饰符,逐个核对
- 加参数校验(
limit > 0 && limit <= 上限):权限只能防「谁」,防不了「改成什么」
怎么防
- 所有改状态的函数逐个确认权限修饰符,用清单核对而不是凭记忆
- 用 OpenZeppelin 的 Ownable / AccessControl,不要手搓权限判断
- 初始化函数必须带 initializer 修饰符,并在 constructor 里 _disableInitializers()
- 避免把「所有权转移」和「放弃所有权」混在一起;放弃权限(renounceOwnership)要慎重
- 多签 + 时间锁:关键操作不应该是单地址一句话就能执行的
- 权限变更必须发事件,方便链上监控
真实案例
2017Parity 多签钱包约 15 万 ETH(当时约 3000 万美元)
未保护的 initWallet 被抢注,随后库合约被 selfdestruct,资金永久冻结
2021Poly Network约 6.11 亿美元
跨链签名校验逻辑缺陷,攻击者伪造了 keeper 身份(后大部分归还)
2022Wormhole约 3.26 亿美元
签名验证被绕过,攻击者「凭空」铸造了 wrapped ETH
2022Nomad Bridge约 1.9 亿美元
初始化导致可信根被设为 0,之后任何人都能提交「有效」消息,酿成群体哄抢
审计这个合约时要问的问题
- 1.列出所有 external 函数,逐个确认「谁能调」,有没有漏的?
- 2.权限判断用的是 msg.sender 还是 tx.origin?
- 3.初始化函数能否被调用第二次?实现合约本身能否被初始化?
- 4.有没有「先部署、后初始化」的两步流程?中间窗口被利用会怎样?
- 5.owner 能做的事里,哪一件会导致用户资金损失?它是否受多签 / 时间锁约束?
这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。
静态扫描能发现它吗
能——扫描引擎里有 5 条规则与它相关:
unrestricted-mintowner-mintprivileged-withdrawno-renouncepausable但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。