W3
严重权限控制静态扫描有 5 条规则

访问控制缺失

Missing / Broken Access Control

特权函数忘了加权限判断,或者权限判断写错(比如用 tx.origin、只校验参数不校验调用者),任何人都能执行本该只有管理员能做的事。

根因

Solidity 里 函数默认是 public 的,而权限必须显式声明。更隐蔽的情况是:权限判断写了但写错了——最常见的三种是漏掉 modifier、初始化函数可被重复调用、以及权限字段本身可被改写。

攻击是怎么一步步发生的

  1. 1
    部署者

    把库合约部署上链,准备稍后初始化自己的钱包

  2. 2
    攻击者

    监控内存池,发现新部署的库合约,抢先调用 initWallet(attacker)

  3. 3
    库合约

    owner 被设为攻击者,且没有「已初始化」标记,无法补救

  4. 4
    攻击者

    调用 execute / selfdestruct 等函数,摧毁库合约

  5. 5
    所有依赖方

    依赖该库的钱包因 delegatecall 目标被毁而永久无法提款

有漏洞的版本

不要照抄solidity
Solidity22
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 事件的关键
  • 没有权限检查的函数即使当前是空实现,也应视为高危——它会在升级中被赋予真实逻辑

修复版本

推荐写法solidity
Solidity30
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

但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。

延伸阅读

同属「权限控制」的其它 1

下一步:想知道这条在手上的合约里有没有出现,用Rug Pull 扫描器,它会检查前面列出的 5 条相关规则。 审计检查清单可以把它落成一条可勾选、可写备注的记录,最后导出 Markdown 报告;想先弄懂这一类漏洞背后的通用思路,去参考手册看对应的语言与标准条目。