简介
拒绝服务 ( Dos - Denial of Service )
简要概述
DoS 攻击在智能合约中常出现于以下场景:
攻击者通过让某个必要操作持续失败,从而阻止整个合约继续执行关键流程(如奖励分发、批量提款、循环遍历列表等)。
典型示例: 合约在循环中向所有参与者发送 ETH,攻击者故意将自己的 fallback/receive 函数设计为 永远 revert,导致程序在遍历到攻击者地址时整个交易回滚,进而导致奖励分发/提款功能长期瘫痪
易受攻击模式:
function distribute() external {
for (uint256 i = 0; i < participants.length; i++) {
address user = participants[i];
uint256 amount = rewards[user];
// 任意一次 transfer 失败,整个分发流程就会 revert
payable(user).transfer(amount);
}
}
攻击者只需在列表中占据一个位置,即可长期阻塞整个流程。
改进方法
使用 Pull Payment
不再向用户“发送奖励”,而是让用户自己来“领取奖励”,这样单个用户的失败不会影响其他用户
function claim() external {
uint256 amount = rewards[msg.sender];
require(amount > 0, "no reward");
// Effects(先修改状态)
rewards[msg.sender] = 0;
// Interactions(再转账)
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "send failed");
}
CEI 说明:
- Checks:检查 reward 是否大于 0
- Effects:先清零 reward 防止重入
- Interactions:最后转账,不会阻塞其他用户
在必须 push 的场景中采用“非阻塞式分发”
避免因为某个地址转账失败而导致整个循环回滚
function distribute() external {
for (uint256 i = 0; i < participants.length; i++) {
address user = participants[i];
uint256 amount = rewards[user];
(bool ok, ) = payable(user).call{value: amount}("");
if (!ok) {
// 可记录失败用户,稍后单独处理
// 不阻塞整体流程
}
}
}
处理 ETH 不当 ( Mishandling of ETH )
简要概述
在Solidity智能合约中,“mishandling ETH”错误常指对以太币(ETH)接收和处理的不当管理,尤其在使用delegatecall的批量函数(如提供的batch函数)时。该函数允许通过delegatecall执行多个内部调用,并在同一交易上下文中共享msg.value。主要问题是:如果批量调用包括payable函数(如需要特定msg.value的进入抽奖函数),每个子调用都会看到相同的msg.value,导致合约仅接收一次ETH,但执行多次操作(如多次进入)。例如,在Puppy Raffle审计中,攻击者可通过batch调用多次enterRaffle(每个检查msg.value == entranceFee),仅支付一次费用却进入多次,造成资金损失或不公平。此外,依赖address(this).balance计算费用也易被操纵(如通过selfdestruct强制发送ETH)
典型示例
function batch(
bytes[] calldata calls,
bool revertOnFail
) external payable returns (bool[] memory successes, bytes[] memory results) {
successes = new bool<a href="calls.length" target="_blank" rel="noopener noreferrer nofollow"></a>;
results = new bytes<a href="calls.length" target="_blank" rel="noopener noreferrer nofollow"></a>;
for (uint256 i = 0; i < calls.length; i++) {
(bool success, bytes memory result) = address(this).delegatecall(calls[i]);
require(success || !revertOnFail, _getRevertMsg(result));
successes[i] = success;
results[i] = result;
}
}
改进方法
避免共享msg.value:在payable函数中,使用内部会计变量跟踪接收的ETH,而不是依赖msg.value。例如,在enterRaffle中记录累积费用:totalFees += entranceFee; require(msg.value >= totalFees );。对于批量调用,确保每个子调用不假设独立的msg.value,或禁止批量payable操作
以下是经过逻辑化重构和改进后的方法说明:
使用内部余额跟踪机制
contract FeeCollector {
// 内部余额账本
uint256 private _collectedFees;
event FeeCollected(address indexed from, uint256 amount);
event FeesWithdrawn(address indexed to, uint256 amount);
// 正规收费入口 - 更新内部账本
function collectFee() external payable {
require(msg.value > 0, "No fee provided");
_collectedFees += msg.value;
emit FeeCollected(msg.sender, msg.value);
}
// 接收任意转账(可选,如有需要)
receive() external payable {
// 可选择是否记录到内部账本
// _collectedFees += msg.value;
}
// 安全的提款机制
function withdrawFees(address payable recipient) external onlyOwner {
uint256 amount = _collectedFees;
require(amount > 0, "No fees to withdraw");
// 重置内部账本前转账(防止重入)
_collectedFees = 0;
(bool success, ) = recipient.call{value: amount}("");
require(success, "Transfer failed");
emit FeesWithdrawn(recipient, amount);
}
// 提供两个余额视图
function getInternalBalance() public view returns (uint256) {
return _collectedFees; // 合约记录应得金额
}
function getContractBalance() public view returns (uint256) {
return address(this).balance; // 实际链上余额(可能包含意外资金)
}
}
实施安全的批量调用机制
- 方案A:使用
call隔离执行环境(推荐)
contract BatchExecutor {
bool private _locked;
modifier nonReentrant() {
require(!_locked, "ReentrancyGuard: reentrant call");
_locked = true;
_;
_locked = false;
}
// 安全批次执行 - 使用call隔离上下文
function executeBatch(
address[] calldata targets,
bytes[] calldata data,
uint256[] calldata values
) external payable nonReentrant returns (bytes[] memory results) {
require(targets.length == data.length, "Array length mismatch");
require(targets.length == values.length, "Values length mismatch");
results = new bytes[](targets.length);
for (uint256 i = 0; i < targets.length; i++) {
// 防护1:限制单次调用价值
require(values[i] <= msg.value / targets.length, "Value exceeds allowance");
// 防护2:禁止高危目标
require(!isBlacklisted(targets[i]), "Target blacklisted");
// 使用call而非delegatecall - 不共享存储
(bool success, bytes memory result) = targets[i].call{
value: values[i]
}(data[i]);
require(success, string(abi.encodePacked("Call failed at index ", i)));
results[i] = result;
}
// 返还剩余ETH
if (address(this).balance > 0) {
(bool refundSuccess, ) = msg.sender.call{
value: address(this).balance
}("");
require(refundSuccess, "Refund failed");
}
}
// 目标合约黑名单机制
mapping(address => bool) private _blacklisted;
function isBlacklisted(address target) public view returns (bool) {
return _blacklisted[target];
}
}
- 方案B:严格管控的
delegatecall(如需存储共享)
contract TrustedDelegateBatch {
// 防护1:仅允许白名单合约
mapping(address => bool) private _whitelistedImplementations;
// 防护2:函数签名限制(禁止payable函数)
bytes4 private constant PAYABLE_SELECTOR = 0x00000000; // 实际需定义具体函数
modifier onlyWhitelisted(address impl) {
require(_whitelistedImplementations[impl], "Implementation not whitelisted");
_;
}
// 严格控制的批量delegatecall
function delegatecallBatch(
address[] calldata implementations,
bytes[] calldata data
) external onlyOwner {
require(implementations.length == data.length, "Length mismatch");
for (uint256 i = 0; i < implementations.length; i++) {
address impl = implementations[i];
// 多重验证
require(_whitelistedImplementations[impl], "Untrusted implementation");
require(!isPayableFunction(data[i]), "Cannot call payable via delegatecall");
// 执行delegatecall
(bool success, ) = impl.delegatecall(data[i]);
require(success, "Delegatecall failed");
}
}
// 检测是否为payable函数
function isPayableFunction(bytes calldata data) internal pure returns (bool) {
if (data.length < 4) return false;
bytes4 selector = bytes4(data[0:4]);
// 实际应维护payable函数选择器列表
return selector == PAYABLE_SELECTOR;
}
}
存储碰撞 ( Storage Collision )
简要概述
在Solidity可升级智能合约中使用代理模式(如UUPS或Transparent Proxy)时,“storage collision”(存储碰撞)是指代理合约和实现合约的存储布局冲突,导致变量覆盖或读取错误数据。代理通过delegatecall执行实现合约逻辑,共享存储槽。如果实现合约升级时添加或重排序状态变量,而未预留槽位,会碰撞代理的固定槽(如admin或implementation地址),造成数据损坏、安全漏洞(如意外访问权限)或合约不可用。这在代理有自身变量时更常见,常因不兼容升级引发
典型示例
代理合约存储槽: [0: implementation地址]
V1合约布局: [0: owner] // 通过代理delegatecall访问,owner实际存储在代理的槽0,覆盖了implementation
V2合约布局: [0: newVar, 1: owner] // 升级后,newVar占据了代理的槽0,覆盖了implementation地址
改进方法
使用存储间隙(Storage Gaps)
原理:在合约末尾定义一个固定长度的数组(如
uint256[50] private __gap;),把未来可能插入的变量占用的槽预留出来,从而避免新增变量导致前面变量槽位移动推荐实践:
- 在每个可升级实现合约(尤其是基础合约/父合约)末尾都添加
__gap;如果合约通过多级继承,每一级都应考虑间隙以避免父合约间的槽位冲突。 - 选择合适的初始间隙大小(常见为50或100),如果未来需要添加变量则在升级时减少
__gap长度以腾出槽位(注意:减少__gap会改变已发布合约字面代码,必须通过升级实现合约逻辑变更)
- 在每个可升级实现合约(尤其是基础合约/父合约)末尾都添加
代码示例:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract MyImplV1 { uint256 public a; address public owner; // 预留 50 个 uint256 槽 uint256[50] private __gap; }注意:
__gap只是约定俗成的命名(OpenZeppelin 使用该命名),名称本身无特殊含义,但必须在源码中保留对应类型/大小
最小化代理存储
- 原理与建议:代理应尽量只包含与代理机制密切相关的最低限度存储(例如 implementation/admin/rollback slot),避免在代理合约中声明业务状态变量。这样实现合约的状态全由实现合约管理,减少冲突面
- 使用成熟代理实现:优先使用社区审计过的代理实现(如 OpenZeppelin 的
ERC1967Proxy/TransparentUpgradeableProxy/ UUPS 模式实现),它们把代理相关槽设置为固定的、通过 assembly 访问的 slot(避免使用普通 state variable) - 举例:ERC1967 使用特定 slot 常量,不会与普通声明变量冲突(见下节 StorageSlot 示例)
布局兼容性检查(工具化验证)
推荐工具:OpenZeppelin Upgrades 插件(Hardhat/Truffle 插件
@openzeppelin/hardhat-upgrades/@openzeppelin/truffle-upgrades)在deploy或upgrade时会做存储布局校验并给出不兼容警告本地验收流程建议:
- 在本地或 CI 中调用
hardhat-upgrades的validate或直接执行upgrade的 dry-run。 - 对比
v1和v2的存储布局(工具会给出字段/槽位映射与潜在冲突),并把审批日志作为升级记录。 - 重大变更(例如删除字段、改变类型、修改继承结构)应引发人工审查或回滚计划。
- 在本地或 CI 中调用
注意点:自动工具无法替代人工审计——当你在合约中用 assembly、delegate patterns 或自定义 slot 时,务必手动验证
采用命名存储或 Eternal Storage(非顺序依赖)
命名存储(Unstructured / Named Slots):通过 keccak 或固定 slot 常量把变量放到计算槽位中,避免依赖编译器分配的连续槽。适合把关键代理字段或者某些升级敏感字段放在固定 slot。
示例(使用 OpenZeppelin StorageSlot):
// 例:保存 implementation 地址在 ERC1967 指定 slot bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; function _getImpl() internal view returns (address) { return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value; } function _setImpl(address newImpl) internal { StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImpl; }优点:只要 slot 常量不变,未来实现合约的 state 变量顺序就不会影响这些 key-value 存储
Eternal Storage:把所有业务状态以映射/键值方式存放在一个专门的存储合约里(如
mapping(bytes32 => uint256)),用实现合约作为逻辑层。优点是实现与存储完全解耦,升级逻辑不影响存储结构。缺点是牺牲可读性与类型检查,需要严谨的键命名与封装层示例模式(简化):
contract EternalStorage { mapping(bytes32 => uint256) uintStorage; mapping(bytes32 => address) addressStorage; // getter/setter... }
签名重放(Signature Replay)
简要概述
定义:签名重放(Signature Replay)是指攻击者重复使用某个合法用户先前生成的数字签名,去重复执行原本只应被执行一次或只在特定上下文有效的操作,从而造成资金/权限被滥用或逻辑被绕开
为什么会发生:
- 签名的数据不包含唯一标识(nonce、operation ID)或上下文信息(chainId、合约地址、用途),导致同一签名可在多个时间或多个环境重复验证通过。
- 验证逻辑仅依赖签名恢复出的地址而不检查签名是否已被使用或是否过期。
- 跨链或跨合约复用:攻击者把在链 A 上得到的签名拿到链 B 或另一个合约上重放。
常见场景
同合约内重复提交(缺 nonce) 合约实现只验证
recover后的签名者地址,但没有记录或消耗 nonce。攻击者或任意人可以把同一签名多次送到合约,导致同一笔“签名授权的转账/操作”被重复执行多次。跨链/跨合约重放(缺上下文) 签名里只包含 (
from,to,amount),没有包含chainId或合约地址。攻击者将签名在另一条链或另一个地址相同的合约上重放,导致资金在不应发生的地方被转移。
典型示例
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract VulnerableSig {
using ECDSA for bytes32;
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
// 脆弱函数:签名只基于 (from, to, amount)
function transferWithSig(
address from,
address to,
uint256 amount,
bytes memory signature
) external {
bytes32 msgHash = keccak256(abi.encodePacked(from, to, amount));
address signer = msgHash.toEthSignedMessageHash().recover(signature);
require(signer == from, "invalid sig");
require(balance[from] >= amount, "insufficient");
balance[from] -= amount;
balance[to] += amount;
}
}
攻击场景说明:
- Alice 签名同意把 1 ETH 从 Alice 转到 Bob(签名的数据只含 from/to/amount)。
- Mallory 拿到这个签名并调用
transferWithSig(Alice,Bob,1, sig)—— 第一次成功。 - 因为合约没有 nonce 或标记,Mallory 可以再次调用同一个签名,重复将 1 ETH 转多次(直到 Alice 余额耗尽)。
- 或者 Mallory 把签名在另一条链或另一合约地址上用相同接口提交(如果另一个合约也按同样方式验证),实现跨环境重放
改进方法
EIP-712 + nonces + deadline
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SafeSig is EIP712 {
using ECDSA for bytes32;
mapping(address => uint256) public nonces;
mapping(address => uint256) public balance;
bytes32 private constant TRANSFER_TYPEHASH = keccak256(
"Transfer(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)"
);
constructor() EIP712("SafeSig", "1") {}
function deposit() external payable {
balance[msg.sender] += msg.value;
}
// 使用 EIP-712 typed data,包含 nonce 和 deadline(并由 domain separator 隐含合约地址 & chainId)
function transferWithSig(
address from,
address to,
uint256 amount,
uint256 nonce,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "expired");
require(nonce == nonces[from], "invalid nonce");
bytes32 structHash = keccak256(
abi.encode(
TRANSFER_TYPEHASH,
from,
to,
amount,
nonce,
deadline
)
);
bytes32 digest = _hashTypedDataV4(structHash); // 包含 domain separator (chainId + contract地址等)
address signer = ECDSA.recover(digest, signature);
require(signer == from, "invalid sig");
require(balance[from] >= amount, "insufficient");
// 消耗 nonce,防止重放
nonces[from] += 1;
balance[from] -= amount;
balance[to] += amount;
}
}
好的,遵照您的要求,以下是以“简要概述”和“改进方法”为目录结构,关于MEV攻击的详细阐述,包含典型示例和安全实践代码。
最大可提取价值 (MECV- Maximal Extractable Value)
简要概述
什么是MEV攻击? MEV(最大可提取价值)攻击并非传统意义上的安全漏洞,而是区块链(尤其是以太坊)透明性带来的经济激励博弈。它指矿工、验证者或专门的搜索者通过添加、删除或重新排序区块内的交易,提取超过标准区块奖励和Gas费的额外利润。这种“价值提取”行为,往往以牺牲普通用户利益为代价
安全风险
- 用户资产损失:用户面临更高的交易滑点、交易失败或支付远高于预期的Gas费。
- 网络健康损害:加剧链上拥堵,导致Gas价格剧烈波动,损害用户体验。
- 公平性破坏:违背“交易按Gas价公平排序”的朴素认知,使拥有先进机器人的参与者获得不公优势。
- 中心化风险:MEV的高额利润可能促使验证者集中化(加入少数几个最大利润矿池),威胁网络的去中心化根基。
典型示例 以下智能合约和场景展示了MEV攻击(特别是三明治攻击)如何利用一个简单的AMM DEX
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 一个简易的、易受攻击的AMM代币兑换合约
contract VulnerableAMM {
mapping(address => uint256) public reserves;
address public immutable tokenA;
address public immutable tokenB;
constructor(address _tokenA, address _tokenB) {
tokenA = _tokenA;
tokenB = _tokenB;
}
// 根据常量乘积公式计算兑换数量
function getAmountOut(uint256 amountIn, address fromToken) public view returns (uint256) {
uint256 reserveIn = reserves[fromToken];
uint256 reserveOut = reserves[fromToken == tokenA ? tokenB : tokenA];
return (amountIn * reserveOut) / (reserveIn + amountIn);
}
// 执行代币兑换(核心漏洞:无保护的大额交易)
function swap(uint256 amountIn, address fromToken) external {
address toToken = fromToken == tokenA ? tokenB : tokenA;
uint256 amountOut = getAmountOut(amountIn, fromToken);
// 更新储备金(先转账后更新,在复杂场景下可能引入重入,此处简化)
reserves[fromToken] += amountIn;
reserves[toToken] -= amountOut;
// 假设这里通过代币合约进行转账(此处省略ERC20调用细节)
// 关键:用户的大额 `amountIn` 会显著影响 `getAmountOut` 的价格!
}
function addLiquidity(uint256 amountA, uint256 amountB) external {
// ... 添加流动性逻辑
}
}
攻击模拟(三明治攻击流程):
- 监控:攻击者(搜索者)的机器人监控内存池,发现一笔来自普通用户的、即将发生的大额
swap交易(例如,用大量USDC购买ETH)。 - 前置交易:攻击者立即构造一笔高Gas费的
swap交易,抢在用户之前用少量资金购买ETH。这笔交易执行后,AMM池中ETH价格上涨。 - 受害者交易:用户的大额交易得以执行,但由于ETH价格已被推高,用户收到的ETH数量远少于预期,承受巨大滑点损失。
- 后置交易:攻击者再发起第二笔交易,将第一步买入的ETH卖出,由于价格被用户交易进一步推高,攻击者获利了结。用户的损失即攻击者的利润。
改进方法
针对MEV攻击的防护需要从协议设计、交易发送策略和网络层等多个维度进行
协议/应用层设计改进
- 使用批量拍卖或限价单
// 概念性代码:采用类似CowSwap的批量拍卖结算
contract BatchAuctionDEX {
struct Order {
address sellToken;
address buyToken;
uint256 sellAmount;
uint256 buyAmountMin; // 最低可接受买入量(限价)
address user;
bool filled;
}
Order[] public orders;
// 用户提交限价订单,不立即执行
function placeOrder(
address sellToken,
address buyToken,
uint256 sellAmount,
uint256 buyAmountMin
) external {
orders.push(Order({
sellToken: sellToken,
buyToken: buyToken,
sellAmount: sellAmount,
buyAmountMin: buyAmountMin,
user: msg.sender,
filled: false
}));
// 将用户资产转入合约托管
// IERC20(sellToken).transferFrom(msg.sender, address(this), sellAmount);
}
// 由 solver(求解器)定期(如每1分钟)计算并结算一个批次内的所有订单
// 使用统一的清算价格,三明治攻击无法在单个批次内获利
function settleBatch(uint256[] calldata orderIds, uint256 clearingPrice) external {
// ... 复杂逻辑:匹配买卖订单,按统一价格结算
// 所有该批次的订单都按此价格成交,抢跑无效。
}
}
- 实施“提交-揭示”机制
contract CommitRevealSwap {
struct Commit {
bytes32 commitHash; // = keccak256(abi.encodePacked(secret, amount, deadline, ...))
uint256 revealDeadline;
}
mapping(address => Commit) public commits;
// 第一阶段:提交承诺
function commitSwap(bytes32 _commitHash) external payable {
commits[msg.sender] = Commit({
commitHash: _commitHash,
revealDeadline: block.timestamp + 1 hours
});
}
// 第二阶段:揭示交易细节并执行
function revealAndSwap(
uint256 secret,
uint256 amountIn,
uint256 minAmountOut,
uint256 deadline
) external {
Commit memory c = commits[msg.sender];
require(block.timestamp <= c.revealDeadline, "Reveal expired");
require(keccak256(abi.encodePacked(secret, amountIn, minAmountOut, deadline)) == c.commitHash, "Invalid reveal");
// 删除承诺,防止重放
delete commits[msg.sender];
// 执行实际兑换逻辑(此时交易细节已无法被抢跑)
// _safeSwap(amountIn, minAmountOut, deadline);
}
}
用户/前端层保护实践
- 使用隐私RPC与交易打包服务
// 前端代码示例:使用Flashbots Protect RPC发送交易
import { ethers } from "ethers";
// 连接到Flashbots Protect RPC,而非公共的Infura/Alchemy
const provider = new ethers.providers.JsonRpcProvider('https://rpc.flashbots.net');
const wallet = new ethers.Wallet(privateKey, provider);
// 发送交易 - 它将直接发送给区块构建者,不进入公共内存池
const tx = await wallet.sendTransaction({
to: '0x...',
value: ethers.utils.parseEther('0.1'),
// 可以设置更低Gas价格,因为不参与公开竞价
maxFeePerGas: ethers.utils.parseUnits('30', 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits('2', 'gwei'),
});
- 设置合理滑点与截止时间
// 在DEX前端交互时,应严格限制滑点容忍度
interface IDEXRouter {
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin, // 关键:根据当前链上价格计算,并设置一个小的、合理的滑点(如0.5%)
address[] calldata path,
address to,
uint deadline // 设置较短的截止时间,防止交易在内存池中停留过久
) external returns (uint[] memory amounts);
}
网络/基础设施层解决方案
- 采用提议者-构建者分离 这是以太坊协议层的根本性改进,核心思想是将交易排序的权力(构建者)与区块生产的权力(提议者/验证者)分离,并通过竞价市场使MEV利润回归网络
// 概念性伪代码,说明PBS流程中的角色
interface IBlockBuilder {
function bidForBlock(Bid calldata bid) external payable returns (bool);
}
interface IRelay {
function submitBlock(BuiltBlock calldata block, address builder, uint64 bidAmount) external;
}
// 1. 搜索者将包含高价值交易(套利、清算)的“捆绑包”发送给构建者。
// 2. 构建者聚合交易,计算最大利润的区块,并出价竞标。
// 3. 中继者确保提议者获得完整区块,构建者无法作恶。
// 4. 提议者选择出价最高的区块头,无需知晓内容,获得大部分MEV收益。
- 利用MEV-Boost(以太坊上的临时PBS实现) 验证者运行MEV-Boost软件,即可从专业构建者网络获取高价值的区块,显著提升质押收益,同时无需自己运行复杂的MEV提取策略