3
月
在社区成长 3 月
LEVEL 1
1 XP
一、背景:为什么访问控制是智能合约审计的第一道门
在智能合约中,转账、铸币、参数调整、暂停、升级实现等高风险操作,通常只允许特定角色执行。一旦权限检查缺失、初始化函数可重复调用,或者升级权限与业务权限混用,攻击者就可能在不破坏密码学机制的情况下直接取得控制权。
这类问题的危险之处在于:代码往往能够正常编译,普通功能测试也可能全部通过。只有从“谁可以在什么状态下调用什么函数”的角度建立权限模型,才能发现隐藏在正常业务路径之外的风险。本文以本地 Foundry 靶场为例,给出一套可重复执行的访问控制审计方法。所有测试仅面向自有代码和本地实验环境,不涉及任何真实目标。
二、核心原理:权限安全不是一个 onlyOwner 修饰器
完整的权限边界至少包含以下五个维度:
1. 主体:外部账户、治理合约、多签钱包、代理管理员或其他合约。
2. 对象:资金、代币供应量、配置参数、预言机地址、实现合约地址。
3. 操作:提取、铸造、暂停、授权、升级、销毁或迁移。
4. 状态:初始化前后、暂停状态、治理延迟期、升级执行期。
5. 调用上下文:直接调用、代理 delegatecall、回调、批量调用。
审计时不能只搜索 onlyOwner。还要检查权限变量如何初始化、能否被覆盖、管理员是否可以绕过延迟机制,以及代理调用时读取的存储是否仍然符合预期。
三、典型漏洞:公开初始化导致所有权被接管
下面是一个故意保留缺陷的本地示例:
pragma solidity ^0.8.24;
contract UnsafeVault {
address public owner;
bool public paused;
function initialize(address initialOwner) external {
owner = initialOwner;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function setPaused(bool value) external onlyOwner {
paused = value;
}
function withdraw(address payable to, uint256 amount) external onlyOwner {
require(!paused, "paused");
require(to != address(0), "zero address");
to.transfer(amount);
}
receive() external payable {}
}
问题不在 onlyOwner 本身,而在 initialize 可以被任意账户重复调用。部署者即使首先完成初始化,其他账户仍可再次写入 owner。对于代理合约,这类缺陷更常见:实现合约构造函数不会在代理存储中执行,因此必须通过初始化器设置状态,但初始化器又必须保证只能成功执行一次。
四、前置条件与实验环境
建议在隔离目录中安装 Foundry,并固定依赖版本。以下命令只创建本地项目:
forge init access-control-lab
cd access-control-lab
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit
forge --version
建议同时准备:
1. Solidity 0.8.20 或更高版本。
2. 独立测试账户,不使用任何生产私钥。
3. Git 版本控制,用于审查合约与测试变化。
4. 静态分析工具,例如 Slither;安装时应使用独立 Python 虚拟环境。
五、分步骤实践:建立权限矩阵
第一步,枚举外部入口。重点记录 external 和 public 函数,以及 receive、fallback、代币回调和升级入口。可以先使用命令快速定位,再人工确认:
grep -RInE "function |fallback|receive" src test
第二步,建立权限矩阵。例如:
- initialize:仅允许一次;调用者必须符合部署流程。
- withdraw:仅资金管理员;目标地址不可为零;应受暂停或额度约束。
- setPaused:仅暂停管理员;恢复操作可考虑交给更高权限角色。
- upgradeToAndCall:仅升级管理员或治理执行器;最好经过多签和时间锁。
- transferOwnership:仅当前管理员;建议采用两步式交接。
第三步,追踪每个敏感状态变量的所有写入点。不要只看函数名称,应搜索赋值、删除、内部函数和继承覆盖:
grep -RInE "owner\s*=|paused\s*=|grantRole|revokeRole|upgradeTo" src
第四步,检查调用上下文。在代理架构中,delegatecall 执行实现合约代码,却读写代理合约的存储。审计时必须确认存储布局、初始化状态和 _authorizeUpgrade 的权限判断,而不能只审查实现合约的构造函数。
六、编写本地回归测试证明漏洞
下面的 Foundry 测试只针对本地部署的故意脆弱合约:
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/UnsafeVault.sol";
contract UnsafeVaultTest is Test {
UnsafeVault vault;
address deployer = address(0xA11CE);
address outsider = address(0xB0B);
function setUp() public {
vault = new UnsafeVault();
vm.prank(deployer);
vault.initialize(deployer);
}
function test_ReinitializeChangesOwner() public {
vm.prank(outsider);
vault.initialize(outsider);
assertEq(vault.owner(), outsider);
}
}
执行:
forge test -vvv
这个测试的价值不只是展示问题,还把安全假设转化为可持续运行的回归条件。修复后,应将测试改为期待交易回滚,并纳入持续集成。
七、修复方案:一次性初始化与角色分离
对于可升级合约,可以使用 OpenZeppelin 的 Initializable 与 AccessControlUpgradeable:
pragma solidity ^0.8.24;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
contract SafeVault is Initializable, AccessControlUpgradeable, PausableUpgradeable {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant TREASURER_ROLE = keccak256("TREASURER_ROLE");
function initialize(address admin, address pauser, address treasurer) external initializer {
require(admin != address(0) && pauser != address(0) && treasurer != address(0), "zero address");
__AccessControl_init();
__Pausable_init();
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, pauser);
_grantRole(TREASURER_ROLE, treasurer);
}
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}
function withdraw(address payable to, uint256 amount) external onlyRole(TREASURER_ROLE) whenNotPaused {
require(to != address(0), "zero address");
require(address(this).balance >= amount, "insufficient balance");
(bool ok, ) = to.call{value: amount}("");
require(ok, "transfer failed");
}
receive() external payable {}
}
这里采用了几个防御措施:initializer 阻止重复初始化;暂停权、资金权和最高管理权相互分离;恢复操作交给更高权限;关键地址禁止为零。生产设计中还应把 DEFAULT_ADMIN_ROLE 交给多签或带延迟的治理执行器,而不是长期由单个外部账户持有。
修复后的核心测试应验证重复初始化失败:
function test_ReinitializeReverts() public {
vm.expectRevert();
vm.prank(outsider);
vault.initialize(outsider, outsider, outsider);
}
八、升级合约的专项检查
如果采用 UUPS 模式,还需要检查以下事项:
1. _authorizeUpgrade 是否明确限制为升级角色。
2. 实现合约构造函数是否调用 _disableInitializers(),避免实现实例被单独初始化。
3. 新版本是否改变已有变量的顺序、类型或继承顺序。
4. reinitializer 版本号是否唯一,迁移函数能否重复执行。
5. 升级交易是否经过多签、时间锁和链上事件监控。
6. 升级与初始化数据是否原子执行,避免代理处于半初始化状态。
可以运行:
forge build
forge test -vvv
forge inspect SafeVault storage-layout
若项目使用 OpenZeppelin Upgrades 插件,还应执行其升级兼容性检查。不要仅凭源码变量看起来一致就判断存储安全,因为继承关系和保留槽位也会影响布局。
九、补充测试:从示例测试扩展为不变量
普通单元测试只能覆盖预先想到的路径。权限审计更适合加入模糊测试和不变量测试,例如:
- 不具备 TREASURER_ROLE 的地址永远不能降低金库余额。
- 初始化成功后,任何账户都不能再次改变初始管理员。
- 暂停期间,任何提款路径都必须失败。
- 角色撤销后,旧角色持有者立即失去能力。
- 升级后,历史余额、角色和暂停状态保持不变。
执行更高轮次的本地模糊测试时,可在 foundry.toml 中设置合理的 runs,并保存失败种子以便复现。覆盖率命令如下:
forge coverage
覆盖率高不等于安全,但它能帮助发现敏感分支从未被测试。尤其要关注失败路径、边界金额、零地址、角色交接和暂停状态,而不是只追求行覆盖率数字。
十、静态分析与人工复核
可在本地运行 Slither:
python3 -m venv .venv
source .venv/bin/activate
pip install slither-analyzer
slither .
静态分析适合发现未初始化变量、危险 delegatecall、重入风险和可疑权限配置,但结果可能包含误报。正确流程是:定位告警对应的状态变量和调用路径,判断是否存在可达条件,再编写最小化测试验证。不能把“工具无高危告警”当成审计完成。
十一、风险与注意事项
1. initializer 只能保证函数执行一次,不能自动证明第一次调用者合法。部署脚本应在代理创建交易中原子执行初始化。
2. AccessControl 的 DEFAULT_ADMIN_ROLE 权限很高,通常既能授予也能撤销其他角色,应考虑专用管理规则、两步交接或时间锁。
3. 多签降低单私钥风险,但签名阈值、签名人独立性和交易可读性同样重要。
4. pause 不是万能保护。若所有恢复和提款都依赖同一个已泄露角色,暂停机制可能失效。
5. 使用 call 转账需要结合检查—效果—交互顺序和重入保护评估;本文重点是权限,不代表忽略重入风险。
6. 分叉测试只能使用公开链状态进行防御性验证,不应对不属于自己的真实合约发送交易或尝试获取权限。
7. 私钥、助记词和 RPC 凭据不得写入仓库、测试日志或命令历史。
十二、常见问题
问:已经有 onlyOwner,为什么还需要角色模型?
答:单一 owner 会把暂停、提款、升级和授权集中到一个密钥上。角色分离可以缩小密钥泄露后的影响范围,也便于审计权限变更。
问:构造函数里设置 owner 是否足够?
答:普通非代理合约通常可以,但代理的业务状态位于代理存储中,实现合约构造函数不会替代理完成初始化,因此需要受保护的初始化器。
问:初始化器有 initializer 修饰器就绝对安全吗?
答:不是。还必须确保代理部署后立即初始化、初始参数正确、实现合约禁止初始化,并检查后续 reinitializer 的权限与版本号。
问:审计时先跑工具还是先读代码?
答:建议先理解资产、角色和信任边界,再运行工具辅助定位,最后通过测试验证。否则容易被大量低价值告警干扰。
问:如何判断修复已经闭环?
答:至少应同时具备代码修复、失败路径回归测试、权限矩阵更新、部署脚本校验、静态分析复核和上线后的角色事件监控。
十三、总结
访问控制审计的重点不是数修饰器,而是验证权限生命周期:权限如何产生、如何使用、如何转移、如何撤销,以及在代理、暂停和升级状态下是否仍然成立。可操作的流程是先建立资产与权限矩阵,再追踪敏感变量写入点,通过本地测试证明越权路径,随后采用一次性初始化、职责分离、多签和时间锁修复,最后用模糊测试、不变量测试、存储布局检查和静态分析形成回归闭环。对可升级合约而言,初始化安全与升级授权应被视为同一个权限体系共同审查。
在智能合约中,转账、铸币、参数调整、暂停、升级实现等高风险操作,通常只允许特定角色执行。一旦权限检查缺失、初始化函数可重复调用,或者升级权限与业务权限混用,攻击者就可能在不破坏密码学机制的情况下直接取得控制权。
这类问题的危险之处在于:代码往往能够正常编译,普通功能测试也可能全部通过。只有从“谁可以在什么状态下调用什么函数”的角度建立权限模型,才能发现隐藏在正常业务路径之外的风险。本文以本地 Foundry 靶场为例,给出一套可重复执行的访问控制审计方法。所有测试仅面向自有代码和本地实验环境,不涉及任何真实目标。
二、核心原理:权限安全不是一个 onlyOwner 修饰器
完整的权限边界至少包含以下五个维度:
1. 主体:外部账户、治理合约、多签钱包、代理管理员或其他合约。
2. 对象:资金、代币供应量、配置参数、预言机地址、实现合约地址。
3. 操作:提取、铸造、暂停、授权、升级、销毁或迁移。
4. 状态:初始化前后、暂停状态、治理延迟期、升级执行期。
5. 调用上下文:直接调用、代理 delegatecall、回调、批量调用。
审计时不能只搜索 onlyOwner。还要检查权限变量如何初始化、能否被覆盖、管理员是否可以绕过延迟机制,以及代理调用时读取的存储是否仍然符合预期。
三、典型漏洞:公开初始化导致所有权被接管
下面是一个故意保留缺陷的本地示例:
pragma solidity ^0.8.24;
contract UnsafeVault {
address public owner;
bool public paused;
function initialize(address initialOwner) external {
owner = initialOwner;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function setPaused(bool value) external onlyOwner {
paused = value;
}
function withdraw(address payable to, uint256 amount) external onlyOwner {
require(!paused, "paused");
require(to != address(0), "zero address");
to.transfer(amount);
}
receive() external payable {}
}
问题不在 onlyOwner 本身,而在 initialize 可以被任意账户重复调用。部署者即使首先完成初始化,其他账户仍可再次写入 owner。对于代理合约,这类缺陷更常见:实现合约构造函数不会在代理存储中执行,因此必须通过初始化器设置状态,但初始化器又必须保证只能成功执行一次。
四、前置条件与实验环境
建议在隔离目录中安装 Foundry,并固定依赖版本。以下命令只创建本地项目:
forge init access-control-lab
cd access-control-lab
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit
forge --version
建议同时准备:
1. Solidity 0.8.20 或更高版本。
2. 独立测试账户,不使用任何生产私钥。
3. Git 版本控制,用于审查合约与测试变化。
4. 静态分析工具,例如 Slither;安装时应使用独立 Python 虚拟环境。
五、分步骤实践:建立权限矩阵
第一步,枚举外部入口。重点记录 external 和 public 函数,以及 receive、fallback、代币回调和升级入口。可以先使用命令快速定位,再人工确认:
grep -RInE "function |fallback|receive" src test
第二步,建立权限矩阵。例如:
- initialize:仅允许一次;调用者必须符合部署流程。
- withdraw:仅资金管理员;目标地址不可为零;应受暂停或额度约束。
- setPaused:仅暂停管理员;恢复操作可考虑交给更高权限角色。
- upgradeToAndCall:仅升级管理员或治理执行器;最好经过多签和时间锁。
- transferOwnership:仅当前管理员;建议采用两步式交接。
第三步,追踪每个敏感状态变量的所有写入点。不要只看函数名称,应搜索赋值、删除、内部函数和继承覆盖:
grep -RInE "owner\s*=|paused\s*=|grantRole|revokeRole|upgradeTo" src
第四步,检查调用上下文。在代理架构中,delegatecall 执行实现合约代码,却读写代理合约的存储。审计时必须确认存储布局、初始化状态和 _authorizeUpgrade 的权限判断,而不能只审查实现合约的构造函数。
六、编写本地回归测试证明漏洞
下面的 Foundry 测试只针对本地部署的故意脆弱合约:
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/UnsafeVault.sol";
contract UnsafeVaultTest is Test {
UnsafeVault vault;
address deployer = address(0xA11CE);
address outsider = address(0xB0B);
function setUp() public {
vault = new UnsafeVault();
vm.prank(deployer);
vault.initialize(deployer);
}
function test_ReinitializeChangesOwner() public {
vm.prank(outsider);
vault.initialize(outsider);
assertEq(vault.owner(), outsider);
}
}
执行:
forge test -vvv
这个测试的价值不只是展示问题,还把安全假设转化为可持续运行的回归条件。修复后,应将测试改为期待交易回滚,并纳入持续集成。
七、修复方案:一次性初始化与角色分离
对于可升级合约,可以使用 OpenZeppelin 的 Initializable 与 AccessControlUpgradeable:
pragma solidity ^0.8.24;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
contract SafeVault is Initializable, AccessControlUpgradeable, PausableUpgradeable {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant TREASURER_ROLE = keccak256("TREASURER_ROLE");
function initialize(address admin, address pauser, address treasurer) external initializer {
require(admin != address(0) && pauser != address(0) && treasurer != address(0), "zero address");
__AccessControl_init();
__Pausable_init();
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(PAUSER_ROLE, pauser);
_grantRole(TREASURER_ROLE, treasurer);
}
function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}
function withdraw(address payable to, uint256 amount) external onlyRole(TREASURER_ROLE) whenNotPaused {
require(to != address(0), "zero address");
require(address(this).balance >= amount, "insufficient balance");
(bool ok, ) = to.call{value: amount}("");
require(ok, "transfer failed");
}
receive() external payable {}
}
这里采用了几个防御措施:initializer 阻止重复初始化;暂停权、资金权和最高管理权相互分离;恢复操作交给更高权限;关键地址禁止为零。生产设计中还应把 DEFAULT_ADMIN_ROLE 交给多签或带延迟的治理执行器,而不是长期由单个外部账户持有。
修复后的核心测试应验证重复初始化失败:
function test_ReinitializeReverts() public {
vm.expectRevert();
vm.prank(outsider);
vault.initialize(outsider, outsider, outsider);
}
八、升级合约的专项检查
如果采用 UUPS 模式,还需要检查以下事项:
1. _authorizeUpgrade 是否明确限制为升级角色。
2. 实现合约构造函数是否调用 _disableInitializers(),避免实现实例被单独初始化。
3. 新版本是否改变已有变量的顺序、类型或继承顺序。
4. reinitializer 版本号是否唯一,迁移函数能否重复执行。
5. 升级交易是否经过多签、时间锁和链上事件监控。
6. 升级与初始化数据是否原子执行,避免代理处于半初始化状态。
可以运行:
forge build
forge test -vvv
forge inspect SafeVault storage-layout
若项目使用 OpenZeppelin Upgrades 插件,还应执行其升级兼容性检查。不要仅凭源码变量看起来一致就判断存储安全,因为继承关系和保留槽位也会影响布局。
九、补充测试:从示例测试扩展为不变量
普通单元测试只能覆盖预先想到的路径。权限审计更适合加入模糊测试和不变量测试,例如:
- 不具备 TREASURER_ROLE 的地址永远不能降低金库余额。
- 初始化成功后,任何账户都不能再次改变初始管理员。
- 暂停期间,任何提款路径都必须失败。
- 角色撤销后,旧角色持有者立即失去能力。
- 升级后,历史余额、角色和暂停状态保持不变。
执行更高轮次的本地模糊测试时,可在 foundry.toml 中设置合理的 runs,并保存失败种子以便复现。覆盖率命令如下:
forge coverage
覆盖率高不等于安全,但它能帮助发现敏感分支从未被测试。尤其要关注失败路径、边界金额、零地址、角色交接和暂停状态,而不是只追求行覆盖率数字。
十、静态分析与人工复核
可在本地运行 Slither:
python3 -m venv .venv
source .venv/bin/activate
pip install slither-analyzer
slither .
静态分析适合发现未初始化变量、危险 delegatecall、重入风险和可疑权限配置,但结果可能包含误报。正确流程是:定位告警对应的状态变量和调用路径,判断是否存在可达条件,再编写最小化测试验证。不能把“工具无高危告警”当成审计完成。
十一、风险与注意事项
1. initializer 只能保证函数执行一次,不能自动证明第一次调用者合法。部署脚本应在代理创建交易中原子执行初始化。
2. AccessControl 的 DEFAULT_ADMIN_ROLE 权限很高,通常既能授予也能撤销其他角色,应考虑专用管理规则、两步交接或时间锁。
3. 多签降低单私钥风险,但签名阈值、签名人独立性和交易可读性同样重要。
4. pause 不是万能保护。若所有恢复和提款都依赖同一个已泄露角色,暂停机制可能失效。
5. 使用 call 转账需要结合检查—效果—交互顺序和重入保护评估;本文重点是权限,不代表忽略重入风险。
6. 分叉测试只能使用公开链状态进行防御性验证,不应对不属于自己的真实合约发送交易或尝试获取权限。
7. 私钥、助记词和 RPC 凭据不得写入仓库、测试日志或命令历史。
十二、常见问题
问:已经有 onlyOwner,为什么还需要角色模型?
答:单一 owner 会把暂停、提款、升级和授权集中到一个密钥上。角色分离可以缩小密钥泄露后的影响范围,也便于审计权限变更。
问:构造函数里设置 owner 是否足够?
答:普通非代理合约通常可以,但代理的业务状态位于代理存储中,实现合约构造函数不会替代理完成初始化,因此需要受保护的初始化器。
问:初始化器有 initializer 修饰器就绝对安全吗?
答:不是。还必须确保代理部署后立即初始化、初始参数正确、实现合约禁止初始化,并检查后续 reinitializer 的权限与版本号。
问:审计时先跑工具还是先读代码?
答:建议先理解资产、角色和信任边界,再运行工具辅助定位,最后通过测试验证。否则容易被大量低价值告警干扰。
问:如何判断修复已经闭环?
答:至少应同时具备代码修复、失败路径回归测试、权限矩阵更新、部署脚本校验、静态分析复核和上线后的角色事件监控。
十三、总结
访问控制审计的重点不是数修饰器,而是验证权限生命周期:权限如何产生、如何使用、如何转移、如何撤销,以及在代理、暂停和升级状态下是否仍然成立。可操作的流程是先建立资产与权限矩阵,再追踪敏感变量写入点,通过本地测试证明越权路径,随后采用一次性初始化、职责分离、多签和时间锁修复,最后用模糊测试、不变量测试、存储布局检查和静态分析形成回归闭环。对可升级合约而言,初始化安全与升级授权应被视为同一个权限体系共同审查。