合约安全的专业性不在于"知道重入"这四个字,而在于为每一类攻击面建立成本模型,并把防御写成不可绕过的代码结构。本文按四个高频攻击面拆解,每段给出漏洞原型与对应防御实现。
nonReentrant 只是及格线经典重入已被广泛认知,但真实事故多来自跨函数、跨合约的间接重入——单个函数的锁挡不住它。
// 漏洞原型:withdraw 有锁,但 transfer 通过回调进入 claim
contract Vault {
mapping(address => uint256) public bal;
function withdraw() external nonReentrant {
uint256 amt = bal[msg.sender];
bal[msg.sender] = 0; // 先清零,看似安全
(bool ok, ) = msg.sender.call{value: amt}(""); // 回调里可调 claim()
require(ok);
}
function claim(address u) external { // 无锁,且依赖 bal 之外的共享状态
rewards[u] += pending; // 回调时 pending 尚未更新
}
}防御原则:状态机 + 全局重入锁 + 检查-生效-交互顺序。把所有外部可调用入口纳入同一把锁,并让"生效"严格早于任何外部调用:
contract VaultSafe {
mapping(address => uint256) public bal;
uint256 private _locked = 1;
modifier lock() { require(_locked == 1, "reentrant"); _locked = 2; _; _locked = 1; }
function withdraw() external lock {
uint256 amt = bal[msg.sender];
bal[msg.sender] = 0; // 生效在前
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok, "send failed");
}
function claim(address u) external lock { /* 同一把锁 */ }
}要点:call 而非 transfer(后者 2300 gas 已不足以适配多签钱包);失败必须 require 而非静默吞掉。
用 DEX 现货价当预言机,攻击者可用闪电贷在单笔交易内拉盘、套利、砸盘,操纵成本约等于 gas。防御的核心是时间加权 + 多源交叉校验。
interface IPool { function observe(uint32[] calldata) external view returns (int56[] memory, uint160[] memory); }
contract TwapOracle {
IPool public pool;
uint32 public constant WINDOW = 1800; // 30 分钟
function twap() public view returns (uint256 price) {
uint32[] memory secs = new uint32[](2);
secs[0] = WINDOW; secs[1] = 0;
(int56[] memory tickCum, ) = pool.observe(secs);
int24 avgTick = int24((tickCum[1] - tickCum[0]) / int56(uint56(WINDOW)));
price = uint256(int256(1.0001 ** uint256(uint24(avgTick - (-887272))))); // 简化示意
}
function safePrice(uint256 spot, uint256 floor, uint256 ceil) external view returns (uint256) {
uint256 p = twap();
require(p >= floor && p <= ceil, "TWAP out of sanity band"); // 硬性带宽
require(spot * 100 <= p * 105 && spot * 100 >= p * 95, "spot deviates");
return p;
}
}要点:TWAP 抬高操纵成本至"持续持仓 30 分钟",带宽校验拦截异常值,现货偏离检查捕捉瞬时拉盘。三者叠加,缺一都可能被绕过。
离线签名授权被重放,源于签名缺少链、合约、nonce 三元绑定。用 EIP-712 结构化哈希把域信息焊进签名。
contract Permit {
bytes32 public constant TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public immutable DOMAIN_SEPARATOR;
mapping(address => uint256) public nonces;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("Token"), keccak256("1"), block.chainid, address(this)
));
}
function permit(address owner, address spender, uint256 value, uint256 deadline, bytes calldata sig) external {
require(block.timestamp <= deadline, "expired");
bytes32 structHash = keccak256(abi.encode(TYPEHASH, owner, spender, value, nonces[owner]++, deadline));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
address signer = ecrecover(digest, uint8(sig[64]), bytes32(sig[0:32]), bytes32(sig[32:64]));
require(signer == owner, "bad sig");
// 执行授权
}
}要点:nonces[owner]++ 在验签后递增,天然防重放;chainId 与 address(this) 保证跨链、跨合约签名互不通用;deadline 限制签名寿命。
可升级合约的典型事故是变量顺序不一致导致存储槽错位,链上数据静默损坏且难以回滚。
// 错误:V2 在开头插了新变量,把 V1 的 slot0 挤到 slot1
contract V2Bad { uint256 public newVar; address public owner; uint256 public value; }
// 正确:新变量只能追加在末尾,并用存储间隙预留升级空间
contract V1 {
address public owner; // slot 0
uint256 public value; // slot 1
uint256[48] private __gap; // 预留 48 槽
}
contract V2 {
address public owner;
uint256 public value;
uint256 public fee; // 占用 gap 首槽,安全
uint256[47] private __gap; // 剩余空间
}配合 initializer 防重复初始化,并用 OpenZeppelin 的 UUPSUpgradeable + _authorizeUpgrade 限制升级权限:
function _authorizeUpgrade(address) internal override onlyOwner {}要点:__gap 是唯一无需迁移即可扩展的安全手段;升级前必须跑存储布局 diff 工具(如 hardhat-upgrades 的 validateUpgrade)。
上线前逐项过:外部调用是否都在状态生效之后;预言机是否有多源与带宽;签名是否域分离 + nonce;升级是否布局兼容;权限是否最小化且可移交。再叠加 Slither 静态扫描、Foundry fuzz(testFuzz 随机输入)与 fork 测试模拟真实链上状态。
安全不是"加了 nonReentrant 就完了",而是为每个信任边界写出可证明的防御代码,并让攻击者操纵它的成本高到不划算。这既是工程纪律,也是合约审计时唯一能站得住脚的论证方式。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。