4、访问控制漏洞:谁在调用你的合约?
访问控制,说白了就是「谁有资格做这件事」。
我在审计合约时,发现超过一半的安全漏洞都跟访问控制有关。要么是权限设得太宽,要么是身份验证方式用错了。今天我们就来聊聊这个老生常谈但又容易踩坑的话题。
4.1 tx.origin 与 msg.sender:一字之差,天壤之别
这两个东西,很多新手分不清。我刚开始写合约时也犯过这个错。
msg.sender 是直接调用者。也就是当前交易的发起者,或者上一个合约的地址。
tx.origin 是整个交易链的起点。也就是最初那个发起交易的 EOA 地址。
举个例子:
contract Wallet {
function transfer(address payable _to, uint _amount) public {
require(tx.origin == msg.sender, "Not allowed");
_to.transfer(_amount);
}
}
这段代码有什么问题?
嗯,问题大了。如果用户 A 调用了恶意合约 B,B 再回调 Wallet 的 transfer 函数——此时 tx.origin 还是 A,但 msg.sender 已经变成了 B。所以 require 条件通过了,钱却被 B 转走了。
我曾经审计过一个 DeFi 项目,他们用 tx.origin 做身份校验。结果被钓鱼攻击盗走了 200 万美金。从那以后,我只要看到 tx.origin 出现在 require 里,就会直接标红。
除非你明确知道自己在做什么,否则永远用 msg.sender 而不是 tx.origin。tx.origin 唯一合理的用途,是拒绝合约调用(比如 require(tx.origin == msg.sender) 来禁止合约调用)。
4.2 Ownable 模式:最基础的权限控制
Ownable 是 OpenZeppelin 提供的一个标准合约。它把合约的「主人」单独拎出来,只有主人能执行某些操作。
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyContract is Ownable {
function emergencyStop() public onlyOwner {
// 只有 owner 能调用
}
}
这个模式很简单,但有个致命问题:单点故障。
owner 的私钥一旦泄露,整个合约就完了。我在项目中遇到过这种情况——项目方的私钥被钓鱼,攻击者直接调用了 withdraw 函数把资金全提走了。
🔑 改进方案
- 使用多签钱包作为 owner(比如 Gnosis Safe)
- 添加时间锁,给用户反应时间
- 考虑 renounceOwnership(放弃所有权),但前提是你确定不再需要 owner 功能
4.3 角色权限管理:不止一个主人
Ownable 太简单了。真实项目里,你需要更细粒度的控制。
比如:
- 管理员可以暂停合约
- 矿工可以挖矿
- 普通用户只能转账
这时候就需要角色权限管理。OpenZeppelin 提供了 AccessControl 合约:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract MyToken is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);
}
function mint(address to, uint amount) public onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
}
你看,这样每个角色各司其职。就算某个角色的私钥泄露,攻击者也做不了太多事。
我习惯把角色定义成 bytes32 常量,用 keccak256 生成。这样可读性好,也不容易冲突。另外,记得给 DEFAULT_ADMIN_ROLE 设置多签地址。
4.4 RBAC 实现:从理论到代码
RBAC(基于角色的访问控制)其实不复杂。核心就三件事:
- 定义角色
- 给地址分配角色
- 检查调用者是否有角色
我们手写一个简单的 RBAC:
contract SimpleRBAC {
mapping(bytes32 => mapping(address => bool)) private _roles;
mapping(bytes32 => address[]) private _roleMembers;
event RoleGranted(bytes32 indexed role, address indexed account);
event RoleRevoked(bytes32 indexed role, address indexed account);
modifier onlyRole(bytes32 role) {
require(_roles[role][msg.sender], "Access denied");
_;
}
function grantRole(bytes32 role, address account) public onlyRole(DEFAULT_ADMIN_ROLE) {
_roles[role][account] = true;
_roleMembers[role].push(account);
emit RoleGranted(role, account);
}
function revokeRole(bytes32 role, address account) public onlyRole(DEFAULT_ADMIN_ROLE) {
_roles[role][account] = false;
emit RoleRevoked(role, account);
}
function hasRole(bytes32 role, address account) public view returns (bool) {
return _roles[role][account];
}
}
这段代码虽然能用,但有几个问题:
- revokeRole 没有从 _roleMembers 中移除地址(会导致数组膨胀)
- 没有防止重复 grant
- DEFAULT_ADMIN_ROLE 没有定义
所以,我建议直接用 OpenZeppelin 的 AccessControl,它已经处理好了这些边界情况。
我曾经自己手写 RBAC,结果忘记在 revoke 时清理数组。几个月后,合约因为 gas 耗尽无法执行某些操作。嗯,从那以后我再也不重复造轮子了。
4.5 知识体系总览
下面这张图,帮你理清访问控制的核心脉络:
4.6 总结一下
访问控制是智能合约安全的第一道防线。我见过太多项目因为这点没做好而翻车。
- 用 msg.sender,别用 tx.origin——除非你明确要防合约调用
- Ownable 够用但不够安全——考虑多签和时间锁
- RBAC 更灵活——适合复杂业务场景
- 别自己造轮子——OpenZeppelin 的库经过了大量审计
记住一句话:权限给得越少,漏洞越少。
公众号:蓝海资料掘金营,微信deep3321