本文深入探讨了以太坊智能合约中一种隐蔽但极具破坏性的漏洞:恶意破坏攻击(Griefing Attacks)。文章解释了破坏攻击的原理,分析了易受攻击的代码,并通过真实案例展示了攻击流程。此外,文章还提供了一个安全、高效、抗恶意破坏的智能合约示例,并讨论了相关的防御策略、测试方法和高级工具。
欢迎来到智能合约安全:Solodit 清单系列的第五部分,我们将在这里解决一个微妙但极具破坏性的漏洞:恶意破坏攻击(Griefing Attacks),在 Solodit 清单中也称为 SOL-AM-Griefing。
想象一下,你已经为 DAO 部署了一个去中心化投票系统。一切运行顺利——直到一个恶意行为者开始用旨在失败的交易淹没你的智能合约。这些行为不会窃取资金或改变结果,但它们会浪费 gas,堵塞以太坊的 mempool,并扰乱合法使用。这就是恶意破坏。
恶意破坏攻击利用以太坊的 gas 机制来造成拒绝服务的情况,增加交易成本,并侵蚀用户信任。对于攻击者来说,它们成本低廉,但对于用户和开发者来说却代价高昂。
在本指南中,我们将:
恶意破坏攻击是以太坊生态系统的破坏者。与重入攻击或价格操纵不同,它们的目的不是直接窃取资金或利用逻辑。它们的目标是破坏,针对以太坊的 gas 模型以:
Solodit 清单的 SOL-AM-Griefing 强调 gas 高效的设计、早期输入验证和受限的用户操作,以减轻这些风险。恶意破坏特别阴险,因为对于攻击者来说成本很低——失败的交易仍然会消耗 revert 之前的操作的 gas,对用户的影响大于攻击者,而攻击者可以负担得起垃圾邮件。
恶意破坏攻击利用以太坊的透明 mempool 和 gas 机制,通过提交故意失败的交易,消耗 gas 并扰乱合约操作。攻击者针对具有以下特征的合约:
require
或 revert
语句的功能,这些语句可以预测地失败(例如,投票限制)。2020 年,机器人用低 gas、容易 revert 的交易垃圾邮件阻塞 Uniswap 的 mempool。合法用户不得不支付更高的费用或忍受延迟。这次攻击暴露了开放 mempool 和大量 revert 路径中的弱点。Uniswap 后来引入了:
其他协议(如 Compound)也在其治理系统中面临恶意破坏尝试,其中失败的提案延迟了 DAO 投票。
让我们检查一个简单的投票合约,用户可以为候选人投票,每个候选人限制为 100 票。它缺乏限制使其成为恶意破坏的主要目标。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract VulnerableVoting {
mapping(address => uint256) public votes;
// Vote for a candidate
function vote(address candidate) external {
require(votes[candidate] < 100, "Candidate reached vote limit");
votes[candidate]++;
emit Voted(msg.sender, candidate);
}
event Voted(address indexed voter, address indexed candidate);
}
以下是攻击者如何利用此合约:
vote(candidate)
,递增 votes[candidate]
。当候选人接近限制时(例如,votes[candidate] = 99
),合约变得容易受到攻击。votes[candidate] = 99
的候选人。他们重复调用 vote(candidate)
并降低 gas 费用(例如,5 gwei),因为他们知道每次调用都会由于 require
检查而 revert。votes[candidate]
并评估 require
语句。4. 影响:投票过程变慢,用户面临更高的成本,并且有些人放弃投票。在治理 DAO 中,这可能会扭曲结果或延迟关键决策。
vote
,从而实现垃圾邮件。require(votes[candidate] < 100)
检查是必要的,但会成为攻击者触发 gas 浪费型 revert 的武器。这是工作流程,用 Mermaid 可视化:
Solodit 清单建议限制用户操作,尽早验证输入,并使用 gas 高效的模式来防止恶意破坏。以下是一个安全的投票合约,其中包含这些原则,以及高级功能,如提交-揭示、批量投票和断路器。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract FixedVoting {
mapping(address => uint256) public votes; // Tracks votes per candidate
mapping(address => bool) public hasVoted; // Tracks if user has voted
mapping(address => bytes32) public commitments; // For commit-reveal
address public admin;
bool public paused;
uint256 public constant MAX_VOTES = 100; // Max votes per candidate
uint256 public constant VOTING_WINDOW = 7 days; // Voting period
uint256 public constant COMMIT_WINDOW = 1 hours; // Commit-reveal window
uint256 public votingStart;
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract paused");
_;
}
modifier withinVotingWindow() {
require(block.timestamp <= votingStart + VOTING_WINDOW, "Voting closed");
_;
}
constructor() {
admin = msg.sender;
votingStart = block.timestamp;
}
// Commit a hashed vote intention
function commitVote(bytes32 commitment) external whenNotPaused withinVotingWindow {
require(!hasVoted[msg.sender], "Already committed");
hasVoted[msg.sender] = true;
commitments[msg.sender] = commitment;
emit VoteCommitted(msg.sender, commitment);
}
// Reveal and record vote
function revealVote(address candidate, uint256 nonce) external whenNotPaused withinVotingWindow {
require(commitments[msg.sender] != bytes32(0), "No commitment");
require(block.timestamp <= votingStart + COMMIT_WINDOW, "Commit window expired");
require(keccak256(abi.encodePacked(msg.sender, candidate, nonce)) == commitments[msg.sender], "Invalid commitment");
require(candidate != address(0), "Invalid candidate");
require(votes[candidate] < MAX_VOTES, "Candidate reached vote limit");
delete commitments[msg.sender];
votes[candidate]++;
emit Voted(msg.sender, candidate);
}
// Batch vote for multiple candidates
function voteBatch(address[] calldata candidates) external whenNotPaused withinVotingWindow {
require(!hasVoted[msg.sender], "Already voted");
require(candidates.length <= 10, "Too many candidates");
hasVoted[msg.sender] = true;
for (uint256 i = 0; i < candidates.length; i++) {
if (candidates[i] != address(0) && votes[candidates[i]] < MAX_VOTES) {
votes[candidates[i]]++;
emit Voted(msg.sender, candidates[i]);
}
}
}
// Finalize voting
function finalizeVoting() external onlyAdmin {
require(block.timestamp > votingStart + VOTING_WINDOW, "Voting still active");
paused = true;
emit VotingFinalized();
}
// Pause contract in case of attack
function pause() external onlyAdmin {
paused = true;
emit Paused();
}
// Unpause contract
function unpause() external onlyAdmin {
paused = false;
emit Unpaused();
}
event VoteCommitted(address indexed voter, bytes32 commitment);
event Voted(address indexed voter, address indexed candidate);
event VotingFinalized();
event Paused();
event Unpaused();
}
commitVote
) 以隐藏 mempool 中的详细信息,然后在 1 小时的时间窗口内揭示它 (revealVote
)。这可以防止抢先交易(第 4 部分)并减少恶意破坏的机会。hasVoted
映射确保每个地址仅投票一次,从而限制垃圾邮件。require(!hasVoted[msg.sender])
和 require(candidate != address(0))
之类的检查会尽早失败,从而最大限度地减少 gas 浪费。voteBatch
函数可以有效地处理多个投票,跳过无效的候选人而不会 revert。MAX_VOTES
常量 (100) 限制了每个候选人的投票数,从而防止状态膨胀(第 2 部分)。VOTING_WINDOW
(7 天)和 COMMIT_WINDOW
(1 小时)限制了攻击面。paused
标志和 whenNotPaused
修饰符会在攻击期间停止操作。onlyAdmin
修饰符限制了敏感函数(finalizeVoting
、pause
、unpause
)。MAX_VOTES
上限可以防止状态膨胀,与第 2 部分保持一致。vote(candidate)
,如果 votes[candidate] < 100
,则期望成功。vote(candidate)
,从而触发 revert 并每次 revert 消耗约 20,000 gas。用失败的交易填充 mempool,从而延迟合法的投票并增加 gas 成本(例如,从 20 gwei 到 100 gwei)。commitVote
提交哈希投票,然后通过 revealVote
揭示它或使用 voteBatch
。通过 hasVoted
、MAX_VOTES
和 COMMIT_WINDOW
检查,如果有效则成功。commitVote
、revealVote
或 voteBatch
,但由于 hasVoted
或 MAX_VOTES
而提前 revert。voteBatch
中的无效候选人将被跳过而不会 revert。失败的成本很低(约 5,000 gas)。以下是一个流程图,比较了易受攻击和安全的实现:
为了进一步减轻恶意破坏,请考虑为受垃圾邮件影响的合法用户提供 gas 退款模型,尤其是在高风险的 dApp 中,如治理或 NFT 铸造:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract RefundableVoting is FixedVoting {
mapping(address => uint256) public gasRefunds;
function claimGasRefund() external whenNotPaused {
uint256 amount = gasRefunds[msg.sender];
require(amount > 0, "No refund");
gasRefunds[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Refund failed");
emit GasRefunded(msg.sender, amount);
}
function recordGasRefund(address user, uint256 amount) external onlyAdmin {
gasRefunds[user] += amount;
emit GasRefundRecorded(user, amount);
}
event GasRefunded(address indexed user, uint256 amount);
event GasRefundRecorded(address indexed user, uint256 amount);
}
claimGasRefund
索取退款,使用 pull-over-push 模式(第 1 部分)。恶意破坏攻击可能会放大本系列早期部分的漏洞:
votes
),从而增加合法操作的 gas 成本。为了应对这种情况,请结合防御:
claimGasRefund
来隔离失败并分配 gas 成本。MAX_VOTES
来限制状态增长,从而防止恶意破坏垃圾邮件膨胀。receive() external payable {
revert("Direct ETH deposits not allowed");
}
commitVote
中使用哈希承诺来隐藏投票详细信息,从而减少基于 mempool 的恶意破坏和抢先交易。例如,voteBatch
函数与第 2 部分的批量处理保持一致:
function voteBatch(address[] calldata candidates) external whenNotPaused withinVotingWindow {
require(!hasVoted[msg.sender], "Already voted");
require(candidates.length <= 10, "Too many candidates");
hasVoted[msg.sender] = true;
for (uint256 i = 0; i < candidates.length; i++) {
if (candidates[i] != address(0) && votes[candidates[i]] < MAX_VOTES) {
votes[candidates[i]]++;
emit Voted(msg.sender, candidates[i]);
}
}
}
这会跳过无效的候选人而不会 revert,从而减少 gas 浪费和 DoS 风险。
为了与 Solodit 清单和行业标准保持一致,请采用以下做法:
require(!hasVoted[msg.sender], "Already voted");
require(candidate != address(0), "Invalid candidate");
emit Voted(msg.sender, candidate);
function pause() external onlyAdmin { paused = true; }
uint256 public constant MAX_VOTES = 100;
require(block.timestamp <= votingStart + VOTING_WINDOW, "Voting closed");
votes[candidate]++;
voteBatch
跳过无效输入。截至 2025 年 7 月,这些工具增强了恶意破坏攻击的预防:
slither --detect high-gas
)。为了确保你的合约抵抗恶意破坏:
it("prevents griefing via repeated votes", async () => {
await voting.vote(candidate.address, { from: user });
await expect(voting.vote(candidate.address, { from: user }))
.to.be.revertedWith("Already voted");
});
it("handles vote limit gracefully", async () => {
for (let i = 0; i < 100; i++) {
await voting.vote(candidate.address, { from: accounts[i] });
}
await expect(voting.vote(candidate.address, { from: accounts[101] }))
.to.be.revertedWith("Candidate reached vote limit");
});
it("processes batch votes efficiently", async () => {
const candidates = [candidate1.address, candidate2.address, invalidCandidate.address];
await voting.voteBatch(candidates, { from: user });
expect(await voting.votes(candidate1.address)).to.equal(1);
expect(await voting.votes(candidate2.address)).to.equal(1);
expect(await voting.votes(invalidCandidate.address)).to.equal(0);
});
恶意破坏攻击的目的不是抢劫,而是破坏。通过阻塞 mempool 和浪费 gas,攻击者会降低信任和功能。但是通过 Solodit 清单的 SOL-AM-Griefing 最佳实践——提交-揭示方案、早期验证、批量处理和断路器——你可以构建能够抵御这些破坏企图的 dApp。
继续通过本系列的见解加强你的合约。在第 6 部分中,我们将探讨矿工操纵、MEV 风险以及如何使用 Chainlink VRF 等随机性工具来保护时间敏感的操作。
在那之前——彻底审计,防御性编码,并自信地构建无需信任的应用。
- 原文链接: medium.com/@ankitacode11...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!