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(基于角色的访问控制)其实不复杂。核心就三件事:

  1. 定义角色
  2. 给地址分配角色
  3. 检查调用者是否有角色

我们手写一个简单的 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 知识体系总览

下面这张图,帮你理清访问控制的核心脉络:

访问控制漏洞知识体系 访问控制 身份验证 权限模型 实现方式 tx.origin vs msg.sender 钓鱼攻击风险 Ownable 单主人 RBAC 多角色 OpenZeppelin 自定义实现 核心原则:最小权限 + 深度防御 永远不要信任外部输入,永远检查调用者身份

4.6 总结一下

访问控制是智能合约安全的第一道防线。我见过太多项目因为这点没做好而翻车。

  • 用 msg.sender,别用 tx.origin——除非你明确要防合约调用
  • Ownable 够用但不够安全——考虑多签和时间锁
  • RBAC 更灵活——适合复杂业务场景
  • 别自己造轮子——OpenZeppelin 的库经过了大量审计

记住一句话:权限给得越少,漏洞越少。


公众号:蓝海资料掘金营,微信deep3321