name: security description: Solidity安全模式、常见漏洞和预部署审计清单。防止实际损失的特定代码模式——不仅仅是警告,而是防御性实现。在部署任何合约、审查代码或构建持有或转移价值的任何东西之前使用。
智能合约安全
你可能出错的地方
“Solidity 0.8+ 防止溢出,所以我是安全的。” 溢出是数十个攻击向量之一。当今的大问题包括:重入攻击、预言机操纵、批准漏洞和小数处理错误。
“我测试了它,它工作。” 工作正确不等于安全。大多数漏洞调用函数的方式或值开发者从未考虑过。
“这是一个小合约,不需要审计。” DAO黑客攻击是一个简单的重入错误。Euler漏洞是一个缺失的检查。大小与安全性无关。
关键漏洞(带防御性代码)
1. 代币小数不同
USDC有6位小数,不是18位。 这是“我的钱去哪了?”漏洞的主要来源。
// ❌ 错误 — 假设18位小数。转移1万亿USDC。
uint256 oneToken = 1e18;
// ✅ 正确 — 检查小数
uint256 oneToken = 10 ** IERC20Metadata(token).decimals();
常见小数:
| 代币 | 小数 |
|---|---|
| USDC, USDT | 6 |
| WBTC | 8 |
| DAI, WETH, 大多数代币 | 18 |
在不同小数的代币之间做数学运算时,先归一化:
// 将USDC金额转换为18位小数的内部记账
uint256 normalized = usdcAmount * 1e12; // 6 + 12 = 18 小数
2. Solidity中没有浮点数
Solidity没有float或double。除法截断到零。
// ❌ 错误 — 这等于0
uint256 fivePercent = 5 / 100;
// ✅ 正确 — 基点(1 bp = 0.01%)
uint256 FEE_BPS = 500; // 5% = 500 基点
uint256 fee = (amount * FEE_BPS) / 10_000;
始终先乘后除。 先除 = 精度损失。
// ❌ 错误 — 损失精度
uint256 result = a / b * c;
// ✅ 正确 — 先乘
uint256 result = (a * c) / b;
对于复杂数学,使用定点库如PRBMath或ABDKMath64x64。
3. 重入攻击
外部调用可以在第一次调用完成前回调到你的合约。如果你在外部调用后更新状态,攻击者可以用过时状态重新进入。
// ❌ 漏洞 — 外部调用后更新状态
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool success,) = msg.sender.call{value: bal}(""); // ← 攻击者在这里重新进入
require(success);
balances[msg.sender] = 0; // 太晚了 — 攻击者已经再次提款
}
// ✅ 安全 — 检查-效果-交互模式 + 重入保护
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0, “无款可提”);
balances[msg.sender] = 0; // 交互前效果
(bool success,) = msg.sender.call{value: bal}("");
require(success, “转移失败”);
}
模式:检查 → 效果 → 交互(CEI)
- 检查 — 验证输入和条件
- 效果 — 更新所有状态
- 交互 — 外部调用最后
始终使用OpenZeppelin的ReentrancyGuard作为CEI之上的安全网。
4. SafeERC20
一些代币(尤其是USDT)在transfer()和approve()上不返回bool。标准调用即使在成功时也会回滚。
// ❌ 错误 — 与USDT和其他非标准代币中断
token.transfer(to, amount);
token.approve(spender, amount);
// ✅ 正确 — 处理所有代币实现
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现货价格作为预言机
闪电贷可以在单个交易内操纵任何池的现货价格。这已造成数亿损失。
// ❌ 危险 — 可在单个交易内操纵
function getPrice() internal view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = uniswapPair.getReserves();
return (reserve1 * 1e18) / reserve0; // 现货价格 — 易被操纵
}
// ✅ 安全 — Chainlink 带陈旧性 + 合理性检查
function getPrice() internal view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, “价格陈旧”);
require(price > 0, “无效价格”);
return uint256(price);
}
如果必须使用链上价格数据:
- 使用TWAP(时间加权平均价格)超过30分钟 — 抵抗单区块操纵
- Uniswap V3 通过
observe()内置TWAP预言机 - 对于高价值决策,仍不如Chainlink安全
6. 金库通胀攻击
ERC-4626金库的第一个存款人可以操纵份额价格以窃取后续存款人。
攻击:
- 攻击者存入1 wei → 获得1份额
- 攻击者直接向金库捐赠1000代币(非通过存款)
- 现在1份额 = 1001代币
- 受害者存入1999代币 → 获得
1999 * 1 / 2000 = 0份额(向下取整) - 攻击者赎回1份额 → 获得所有3000代币
修复 — 虚拟偏移:
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(
totalSupply() + 1e3, // 虚拟份额
totalAssets() + 1 // 虚拟资产
);
}
虚拟偏移使攻击不经济 — 攻击者需要捐赠大量代币来操纵比率。
OpenZeppelin的ERC4626实现默认包含此缓解措施。
7. 无限批准
切勿使用type(uint256).max作为批准金额。
// ❌ 危险 — 如果此合约被利用,攻击者会耗尽您的整个余额
token.approve(someContract, type(uint256).max);
// ✅ 安全 — 仅批准所需金额
token.approve(someContract, exactAmountNeeded);
// ✅ 可接受 — 为重复交互批准小倍数
token.approve(someContract, amountPerTx * 5); // 5次交易的价值
如果具有无限批准的合约被利用(代理升级漏洞、治理攻击、未发现的漏洞),攻击者可以耗尽每个授予无限访问权限的用户的每个批准代币。
8. 访问控制
每个状态更改函数都需要显式访问控制。“谁应该能够调用这个?”是第一个问题。
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
// ❌ 错误 — 任何人都可以耗尽合约
function emergencyWithdraw() external {
token.transfer(msg.sender, token.balanceOf(address(this)));
}
// ✅ 正确 — 仅所有者
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, “零金额”);
require(recipient != address(0), “零地址”);
require(amount <= maxDeposit, “超过最大”);
// 现在继续
}
常见遗漏验证:
- 零地址(发送到0x0的代币永远烧毁)
- 零金额(浪费gas,可能导致除以零)
- 批量操作中的数组长度不匹配
- 数组中的重复条目
- 值超过合理范围
预部署安全清单
在部署到生产之前,为每个合约运行此清单。无例外。
- [ ] 访问控制 — 每个管理员/特权函数都有显式限制
- [ ] 重入保护 — CEI模式 + 所有外部调用函数上的
nonReentrant - [ ] 代币小数处理 — 对可能不同小数的代币不硬编码
1e18 - [ ] 预言机安全 — 使用Chainlink或TWAP,不是DEX现货价格。存在陈旧性检查
- [ ] 整数数学 — 先乘后除。关键计算中无精度损失
- [ ] 返回值检查 — 使用SafeERC20处理所有代币操作
- [ ] 输入验证 — 零地址、零金额、所有公共函数的范围检查
- [ ] 事件发射 — 每个状态更改发射事件用于链下跟踪
- [ ] 激励设计 — 维护函数可由任何人调用,只要有足够激励
- [ ] 无无限批准 — 批准精确金额或小有界倍数
- [ ] 转账费用安全 — 如果接受任意代币,测量实际收到金额
- [ ] 测试边缘情况 — 零值、最大值、未授权调用者、重入尝试
MEV & 三明治攻击
MEV(最大可提取价值): 验证者和搜索者可以在区块内重新排序、插入或审查交易。他们通过前置运行您的交易、后置运行或两者来获利。
三明治攻击
对DeFi用户最常见的MEV攻击:
1. 您提交:在Uniswap上交换10 ETH → USDC(滑点1%)
2. 攻击者看到您的交易在内存池中
3. 攻击者前置运行:在您之前购买USDC → 价格上涨
4. 您的交换以更差价格执行(但在您的1%滑点内)
5. 攻击者后置运行:在您之后出售USDC → 从价格差异中获利
6. 您获得的USDC少于真实市场价格
保护
// ✅ 设置明确的最小输出 — 不要将amountOutMinimum设置为0
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: USDC,
fee: 3000,
recipient: msg.sender,
amountIn: 1 ether,
amountOutMinimum: 1900e6, // ← 可接受的最小USDC(保护免受三明治攻击)
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)
代理模式与可升级性
智能合约默认不可变。代理让您升级逻辑,同时保持相同地址和状态。
何时使用代理
- 使用代理: 长期协议,可能需要发布后错误修复或功能添加
- 不使用代理: MVPs、简单代币、设计上不可变的合约、“无人能改变此”是价值主张的合约
代理增加复杂性、攻击面和信任假设。 用户必须信任管理员不会升级到恶意实现。不要仅仅因为您能而使用代理。
UUPS vs 透明代理
| UUPS | 透明 | |
|---|---|---|
| 升级逻辑位置 | 在实现合约中 | 在代理合约中 |
| 用户燃气成本 | 更低(每次调用无管理员检查) | 更高(每次调用检查msg.sender) |
| 推荐 | 是(由OpenZeppelin) | 传统模式 |
| 风险 | 忘记_authorizeUpgrade锁住合约 |
更多燃气开销 |
使用UUPS。 它更便宜、更简单,是OpenZeppelin推荐的。
UUPS实现
// 实现合约(逻辑)
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(); // 防止实现被直接初始化
}
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
// ❌ 错误 — 重新排序存储破坏一切
// V1: uint256 a; uint256 b;
// V2: uint256 b; uint256 a; ← 交换了!‘a’现在读取‘b’的值
// ✅ 正确 — 仅追加
// V1: uint256 a; uint256 b;
// V2: uint256 a; uint256 b; uint256 c; ← 新变量在末尾
EIP-712 签名与 Delegatecall
EIP-712: 类型化结构化数据签名
EIP-712让用户用域分离和重放保护对结构化数据(不仅是原始字节)进行签名。用于无gas批准、元交易和链下订单签名。
何时使用:
- Permit(ERC-2612) — 无gas代币批准(用户签名,任何人可以提交)
- 链下订单 — 链下签名买卖订单,链上结算(0x、Seaport)
- 元交易 — 用户签名意图,中继者提交并支付gas
// EIP-712 域分离器 — 防止跨合约和跨链重放
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, “许可过期”);
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, “无效签名”);
_approve(owner, spender, value);
}
关键属性:
- 域分离器 防止在不同合约或链上重放签名
- Nonce 防止重放同一签名两次
- Deadline 防止陈旧签名在以后使用
- 在实践中,使用OpenZeppelin的
EIP712和ERC20Permit— 不要从零开始实现
Delegatecall
delegatecall在调用者的存储上下文中执行另一个合约的代码。被调用合约的逻辑运行,但读取和写入发生在您的合约的存储上。
如果目标是不可信的,这极其危险。
// ❌ 关键漏洞 — delegatecall 到用户提供的地址
function execute(address target, bytes calldata data) external {
target.delegatecall(data); // 攻击者可以覆盖任何存储槽
}
// ✅ 安全 — delegatecall 仅到可信的、不可变的实现
address public immutable trustedImplementation;
function execute(bytes calldata data) external onlyOwner {
trustedImplementation.delegatecall(data);
}
Delegatecall规则:
- 切勿delegatecall到用户提供的地址 — 允许任意存储操纵
- 仅delegatecall到您控制的合约 — 最好是不可变的
- 存储布局必须匹配 — 调用合约和目标合约必须有相同的存储变量顺序
- 这是代理的工作原理 — 代理delegatecalls到实现,因此实现的代码在代理的存储上运行。这就是为什么对于可升级合约,存储布局如此重要。
自动化安全工具
部署前运行这些:
# 静态分析
slither . # 检测常见漏洞
mythril analyze Contract.sol # 符号执行
# Foundry 模糊测试(内置)
forge test --fuzz-runs 10000 # 用随机输入模糊测试所有测试函数
# 燃气优化(额外)
forge test --gas-report # 识别昂贵函数
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 — 动手安全开发实践