智能合约安全Skill security

这个技能专注于智能合约的安全开发与审计,涵盖Solidity编程中的常见漏洞防御、安全模式实现、预部署审计清单等关键主题。包括重入攻击、代币小数处理、预言机安全、MEV防护、代理升级和EIP-712签名等内容,适用于区块链开发者、安全审计师和任何处理价值转移的合约部署。关键词:智能合约、安全、Solidity、漏洞、审计、区块链、DeFi、重入攻击、预言机、MEV。

智能合约 0 次安装 0 次浏览 更新于 3/24/2026

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没有floatdouble。除法截断到零。

// ❌ 错误 — 这等于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;

对于复杂数学,使用定点库如PRBMathABDKMath64x64

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)

  1. 检查 — 验证输入和条件
  2. 效果 — 更新所有状态
  3. 交互 — 外部调用最后

始终使用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. 攻击者存入1 wei → 获得1份额
  2. 攻击者直接向金库捐赠1000代币(非通过存款)
  3. 现在1份额 = 1001代币
  4. 受害者存入1999代币 → 获得1999 * 1 / 2000 = 0份额(向下取整)
  5. 攻击者赎回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 RPChttps://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 {}
}

关键规则

  1. 使用initializer而不是constructor — 代理不运行构造函数
  2. 切勿更改存储布局 — 仅在新变量的末尾追加,切勿删除或重新排序
  3. 使用OpenZeppelin的可升级合约@openzeppelin/contracts-upgradeable,不是@openzeppelin/contracts
  4. 在构造函数中禁用初始化器 — 防止任何人直接初始化实现
  5. 将升级权限转移到多签 — 切勿将升级权力留给单个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的EIP712ERC20Permit — 不要从零开始实现

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 发现绝不能忽视:

  • 重入漏洞
  • 未检查的返回值
  • 任意delegatecallselfdestruct
  • 未保护的状态更改函数

进一步阅读