关于 security
面向 Solidity 开发者的智能合约安全指南,涵盖常见漏洞、DeFi 风险与合约升级注意事项,并提供防御模式和代码示例。
主要功能
- 01讲解重入与访问控制防护
- 02处理代币精度与安全调用
- 03识别预言机和 MEV 风险
- 04说明 ERC-4626 通胀攻击缓解
- 05介绍代理升级与签名安全
适用场景
- 01审查合约中的常见漏洞
- 02设计安全的代币交互与定价
- 03规划代理合约升级策略
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ austintgriffith/ ethskills --skill security安装后在 Claude Code 中输入/security确认已加载。
详细介绍
智能合约安全
你可能搞错的地方
“Solidity 0.8+ 可以防止溢出,所以我很安全。” 溢出只是数十种攻击向量之一。如今主要的攻击手段包括:重入、预言机操纵、授权漏洞利用,以及小数位处理不当。
“我测试过了,能正常运行。” 正常运行不等于安全。大多数漏洞利用都会以开发者从未考虑过的顺序调用函数,或传入开发者从未考虑过的值。
“这是个小合约,不需要审计。” The DAO 攻击只是一个简单的重入漏洞。Euler 攻击只是因为少了一个检查。合约大小与安全性无关。
关键漏洞(附防御代码)
1. 代币小数位各不相同
USDC 有 6 位小数,不是 18 位。 这是“我的钱去哪了?”类漏洞的首要来源。
// ❌ WRONG — assumes 18 decimals. Transfers 1 TRILLION USDC.
uint256 oneToken = 1e18;
// ✅ CORRECT — check decimals
uint256 oneToken = 10 ** IERC20Metadata(token).decimals();
常见小数位:
| 代币 | 小数位 |
|---|---|
| USDC,USDT | 6 |
| WBTC | 8 |
| DAI、WETH、大多数代币 | 18 |
对小数位不同的代币进行计算时,先统一单位:
// Converting USDC amount to 18-decimal internal accounting
uint256 normalized = usdcAmount * 1e12; // 6 + 12 = 18 decimals
2. Solidity 中没有浮点数
Solidity 没有 float 或 double。除法会截断为零。
// ❌ WRONG — this equals 0
uint256 fivePercent = 5 / 100;
// ✅ CORRECT — basis points (1 bp = 0.01%)
uint256 FEE_BPS = 500; // 5% = 500 basis points
uint256 fee = (amount * FEE_BPS) / 10_000;
务必先乘后除。 先除法会导致精度损失。
// ❌ WRONG — loses precision
uint256 result = a / b * c;
// ✅ CORRECT — multiply first
uint256 result = (a * c) / b;
对于复杂计算,请使用 PRBMath 或 ABDKMath64x64 等定点数库。
3. 重入
外部调用可能会在首次调用完成前回调你的合约。如果你在外部调用后才更新状态,攻击者就可以利用过期状态再次进入合约。
// ❌ VULNERABLE — state updated after external call
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool success,) = msg.sender.call{value: bal}(""); // ← attacker re-enters here
require(success);
balances[msg.sender] = 0; // Too late — attacker already withdrew again
}
// ✅ SAFE — Checks-Effects-Interactions pattern + reentrancy guard
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // Effect BEFORE interaction
(bool success,) = msg.sender.call{value: bal}("");
require(success, "Transfer failed");
}
模式:检查 → 状态变更 → 交互(CEI)
- 检查 — 验证输入和条件
- 状态变更 — 更新所有状态
- 交互 — 最后进行外部调用
除了遵循 CEI,务必使用 OpenZeppelin 的 ReentrancyGuard 作为额外保障。
4. SafeERC20
某些代币(尤其是 USDT)在调用 transfer() 和 approve() 时不会返回 bool。即使调用成功,标准调用也会回滚。
// ❌ WRONG — breaks with USDT and other non-standard tokens
token.transfer(to, amount);
token.approve(spender, amount);
// ✅ CORRECT — handles all token implementations
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(to, amount);
token.safeApprove(spender, amount);
还需留意其他代币特性:
- 转账收费代币: 收到的数量 < 发送的数量。务必比较转账前后的余额。
- 余额弹性代币(stETH): 余额会在没有转账的情况下发生变化。请使用封装版本(wstETH)。
- 可暂停代币(USDC): 如果代币已暂停,转账可能会回滚。
- 黑名单代币(USDC、USDT): 特定地址可能会被禁止交易。
5. 切勿将 DEX 现货价格用作预言机
闪电贷可以在单笔交易中操纵任意池子的现货价格。这已造成数亿美元的损失。
// ❌ DANGEROUS — manipulable in one transaction
function getPrice() internal view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = uniswapPair.getReserves();
return (reserve1 * 1e18) / reserve0; // Spot price — easily manipulated
}
// ✅ SAFE — Chainlink with staleness + sanity checks
function getPrice() internal view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}
如果必须使用链上价格数据:
- 使用 30 分钟以上的 TWAP(时间加权平均价格)——可抵御单区块操纵
- Uniswap V3 内置了 TWAP 预言机,可通过
observe()使用(已验证地址:addresses/SKILL.md) - 对于高价值决策,其安全性仍不如 Chainlink
6. 金库通胀攻击
ERC-4626 金库的首位存款者可以操纵份额价格,从后续存款者手中窃取资产。
攻击过程:
- 攻击者存入 1 wei → 获得 1 份额
- 攻击者直接向金库捐赠 1000 个代币(不通过存款)
- 此时 1 份额 = 1001 个代币
- 受害者存入 1999 个代币 → 获得
1999 * 1 / 2000 = 0 shares(向下取整) - 攻击者赎回 1 份额 → 获得全部 3000 个代币
修复方法——虚拟偏移量:
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(
totalSupply() + 1e3, // Virtual shares
totalAssets() + 1 // Virtual assets
);
}
虚拟偏移量会让攻击变得无利可图——攻击者必须捐赠巨额代币才能操纵比率。
从 v5 开始,OpenZeppelin 的 ERC4626 实现默认包含此缓解措施。
7. 无限授权
切勿将 type(uint256).max 用作授权额度。
// ❌ DANGEROUS — if this contract is exploited, attacker drains your entire balance
token.approve(someContract, type(uint256).max);
// ✅ SAFE — approve only what's needed
token.approve(someContract, exactAmountNeeded);
// ✅ ACCEPTABLE — approve a small multiple for repeated interactions
token.approve(someContract, amountPerTx * 5); // 5 transactions worth
如果获得无限授权的合约遭到利用(代理升级漏洞、治理攻击或尚未发现的漏洞),攻击者就能从所有授予无限访问权限的用户那里,转走其获批的全部代币。
8. 访问控制
每个会更改状态的函数都需要明确的访问控制。首先要问:“谁应该能够调用这个函数?”
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
// ❌ WRONG — anyone can drain the contract
function emergencyWithdraw() external {
token.transfer(msg.sender, token.balanceOf(address(this)));
}
// ✅ CORRECT — only owner
function emergencyWithdraw() external onlyOwner {
token.transfer(owner(), token.balanceOf(address(this)));
}
对于复杂的权限控制,请使用 OpenZeppelin 的 AccessControl,并按角色划分权限(ADMIN_ROLE、OPERATOR_ROLE 等)。
9. 输入验证
永远不要信任输入。验证所有内容。
function deposit(uint256 amount, address recipient) external {
require(amount > 0, "Zero amount");
require(recipient != address(0), "Zero address");
require(amount <= maxDeposit, "Exceeds max");
// Now proceed
}
常见的遗漏验证:
- 零地址(代币发送到 0x0 后将永久销毁)
- 零金额(浪费 Gas,还可能导致除以零)
- 批量操作中的数组长度不匹配
- 数组中存在重复条目
- 数值超出合理范围
MEV 与三明治攻击
**MEV(最大可提取价值):**验证者和搜索者可以在一个区块内重新排序、插入或审查交易。他们可以通过抢跑你的交易、在其后跟单,或两者兼施来获利。
三明治攻击
针对 DeFi 用户最常见的 MEV 攻击:
1. You submit: swap 10 ETH → USDC on Uniswap (slippage 1%)
2. Attacker sees your tx in the mempool
3. Attacker frontruns: buys USDC before you → price rises
4. Your swap executes at a worse price (but within your 1% slippage)
5. Attacker backruns: sells USDC after you → profits from the price difference
6. You got fewer USDC than the true market price
防护
// ✅ Set explicit minimum output — don't set amountOutMinimum to 0
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: msg.sender,
amountIn: 1 ether,
amountOutMinimum: 1900e6, // ← Minimum acceptable USDC (protects against sandwich)
sqrtPriceLimitX96: 0
});
面向用户/前端:
- 使用 Flashbots Protect RPC(
https://rpc.flashbots.net)——将交易发送到私有内存池,避免三明治机器人看到 - 设置严格的滑点限制(主流代币设为 0.5-1%,小型代币设为 1-3%)
- 使用支持 MEV 的 DEX 聚合器(CoW Swap、1inch Fusion),通过求解器而非公共内存池进行路由
MEV 相关的场景:
- 在 DEX 上进行的任何兑换(尤其是大额兑换)
- 任何大额 DeFi 交易(存款、取款、清算)
- 需求旺盛的 NFT 铸造(机器人会抢跑,以便先行铸造)
MEV 无关紧要的场景:
- 简单的 ETH/代币转账
- L2 交易(排序器会按顺序处理交易——没有公共内存池重新排序)
- 私有内存池交易(Flashbots、MEV Blocker)
代理模式与可升级性
智能合约默认不可变。代理让你可以在保持地址和状态不变的同时升级逻辑。
何时使用代理
- **使用代理:**可能需要在上线后修复漏洞或添加功能的长期协议
- **不要使用代理:**MVP、简单代币、按设计不可变的合约,以及“无人能更改”本身就是其价值主张的合约
**代理会增加复杂性、攻击面和信任假设。**用户必须信任管理员不会将合约升级为恶意实现。不要仅仅因为可以使用代理就使用它。
UUPS 与透明代理
| UUPS | Transparent | |
|---|---|---|
| 升级逻辑位置 | 在实现合约中 | 在代理合约中 |
| 用户的 Gas 成本 | 更低(每次调用无需检查管理员) | 更高(每次调用都会检查 msg.sender) |
| 推荐 | 是(OpenZeppelin 推荐) | 旧式模式 |
| 风险 | 忘记 _authorizeUpgrade 会锁定合约 | Gas 开销更高 |
**使用 UUPS。**它更便宜、更简单,也是 OpenZeppelin 推荐的方式。
UUPS 实现
// Implementation contract (the logic)
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // Prevent implementation from being initialized
}
function initialize(address owner) public initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
value = 42;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
关键规则
- 使用
initializer而不是constructor——代理不会运行构造函数 - 绝不要更改存储布局——只能在末尾追加新变量,绝不要删除或重新排序变量
- 使用 OpenZeppelin 的可升级合约——使用
@openzeppelin/contracts-upgradeable,而不是@openzeppelin/contracts - 在构造函数中禁用初始化器——防止任何人直接初始化实现合约
- 将升级权限转交给多签——绝不要将升级权限留给单个 EOA
// ❌ WRONG — reordering storage breaks everything
// V1: uint256 a; uint256 b;
// V2: uint256 b; uint256 a; ← Swapped! 'a' now reads 'b's value
// ✅ CORRECT — only append
// V1: uint256 a; uint256 b;
// V2: uint256 a; uint256 b; uint256 c; ← New variable at the end
EIP-712 签名与 Delegatecall
EIP-712:类型化结构化数据签名
EIP-712 允许用户对结构化数据(而不只是原始字节)进行签名,并提供域分隔和重放保护。可用于免 Gas 授权、元交易和链下订单签名。
适用场景:
- Permit(ERC-2612)——免 Gas 代币授权(用户签名,任何人都可以提交)
- 链下订单——在链下签署买卖订单,在链上结算(0x、Seaport)
- 元交易——用户签署意图,由中继器提交并支付 Gas
// EIP-712 domain separator — prevents replay across contracts and chains
bytes32 public constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function permit(
address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
require(block.timestamp <= deadline, "Permit expired");
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline
));
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01", DOMAIN_SEPARATOR(), structHash
));
address recovered = ecrecover(digest, v, r, s);
require(recovered == owner, "Invalid signature");
_approve(owner, spender, value);
}
主要特性:
- 域分隔符可防止在不同合约或链上重放签名
- Nonce可防止同一签名被重复使用
- 截止时间可防止之后使用过期签名
- 实际开发中,请使用 OpenZeppelin 的
EIP712和ERC20Permit,不要从头实现
Delegatecall
delegatecall 会在调用方的存储上下文中执行另一个合约的代码。被调用合约的逻辑会运行,但读写操作针对的是你自己的合约存储。
如果目标不可信,这种操作极其危险。
// ❌ CRITICAL VULNERABILITY — delegatecall to user-supplied address
function execute(address target, bytes calldata data) external {
target.delegatecall(data); // Attacker can overwrite ANY storage slot
}
// ✅ SAFE — delegatecall only to trusted, immutable implementation
address public immutable trustedImplementation;
function execute(bytes calldata data) external onlyOwner {
trustedImplementation.delegatecall(data);
}
Delegatecall 规则:
- 绝不要对用户提供的地址执行 delegatecall——这会允许任意操纵存储
- 仅对你控制的合约执行 delegatecall,并且最好使用不可变合约
- 存储布局必须匹配——调用合约和目标合约的存储变量顺序必须完全一致
- 代理就是这样工作的——代理会对实现合约执行 delegatecall,因此实现合约的代码会在代理的存储上运行。这就是存储布局对可升级合约如此重要的原因。
自动化安全工具
部署前运行以下工具:
# Static analysis
slither . # Detects common vulnerabilities
mythril analyze Contract.sol # Symbolic execution
# Foundry fuzzing (built-in)
forge test --fuzz-runs 10000 # Fuzz all test functions with random inputs
# Gas optimization (bonus)
forge test --gas-report # Identify expensive functions
绝不能忽略的 Slither 检测结果:
- 重入漏洞
- 未检查的返回值
- 任意
delegatecall或selfdestruct - 未受保护的状态变更函数
延伸阅读
- **OpenZeppelin Contracts:**https://docs.openzeppelin.com/contracts——经过审计、久经考验的实现
- **SWC Registry:**https://swcregistry.io——全面的漏洞目录
- **Rekt News:**https://rekt.news——真实漏洞利用事件的事后分析
- **SpeedRun Ethereum:**https://speedrunethereum.com——通过实践学习安全开发
部署前安全检查清单
每个合约部署到生产环境之前,都要逐项检查。无一例外。
- 访问控制 — 每个管理员/特权函数都有明确的限制
- 暂停功能的权衡 — 如果添加了
Pausable+onlyOwner,请提醒构建者。单个密钥就能冻结所有用户,这会造成审查风险。建议采用时间锁或多签治理。 - 重入保护 — 遵循 CEI 模式,并在所有调用外部合约的函数上使用
nonReentrant - 代币小数位处理 — 对小数位可能不同的代币,不要硬编码
1e18 - 预言机安全 — 使用 Chainlink 或 TWAP,而非 DEX 现货价格。包含数据过期检查
- 整数运算 — 先乘后除。关键计算中不得损失精度
- 检查返回值 — 所有代币操作都使用 SafeERC20
- 输入验证 — 对所有公开函数检查零地址、零金额和取值范围
- 输入验证(高级情况) — 拒绝数组长度不匹配、数组条目重复和不合理的取值范围
- 发出事件 — 每次状态变更都发出事件,以便链下追踪
- 激励机制设计 — 维护函数应能由任何人调用,并提供足够的激励
- 禁止无限授权 — 授权精确金额或较小的有界倍数
- 兼容转账扣费代币 — 如果接受任意代币,应测量实际收到的金额
- MEV/三明治攻击防护(如果进行兑换) — 明确设置
amountOutMinimum,将滑点默认值设得较低,并考虑为面向用户的兑换采用私有内存池路由 - 代理安全(如果可升级) — 使用
initializer(不通过构造函数初始化),禁用实现合约上的初始化器,并使用 OpenZeppelin 可升级合约 - 存储布局安全(如果可升级) — 只能追加存储变量,不得重新排序或删除
- 升级权限(如果可升级) — 将升级权限移交给多签/时间锁,不得交由单个 EOA 掌控
- EIP-712 重放安全(如果使用签名) — 正确强制执行域分隔符、nonce 处理和截止时间
- Delegatecall 安全(如果使用) — 绝不对用户提供的目标执行 delegatecall;只能调用受信任且受控、存储布局兼容的实现
- 运行自动化分析 — 部署前运行 Slither/Mythril 和模糊测试
- 解决分析器发现的严重问题 — 不得存在未解决的重入、未检查的返回值、任意
delegatecall/selfdestruct或未受保护的状态变更函数 - 测试边界情况 — 零值、最大值、未经授权的调用者、重入尝试
- 在区块浏览器上验证源代码 — 每次部署后运行
yarn verify或forge verify-contract。未经验证的合约无法供用户审计,看起来与诈骗合约无异
完成前:通过 feedback/SKILL.md 发送一行反馈,提出问题或表达赞赏。