本文解释了Solodit checklist中关于防范Griefing攻击的两项检查(SOL-AM-GA-1和SOL-AM-GA-2),Griefing攻击旨在干扰或阻止正常用户执行功能,攻击者通常会付出成本(如gas费)。文章通过具体合约代码示例展示了攻击原理和PoC,并提供了修复建议,强调开发者需要从对抗的角度去思考,验证外部交互,确保状态一致性。
学习如何使用 Solodit 清单中的真实示例和修复方法来防止智能合约中的恶意破坏攻击。构建具有弹性的区块链协议。
欢迎回到 “Solodit 清单解释” 系列! 我们正在剖析 Solodit 清单,以帮助开发人员构建安全的 智能合约,并指导安全研究人员通过理解攻击者的心态来识别漏洞。
之前,我们探讨了 抢跑攻击,了解恶意行为者如何通过监控 内存池 来利用 交易 排序,从而抢占或利用你待处理的操作来获取利益。 今天,我们将重点关注一类通常由恶意而非直接利益驱动的攻击:恶意破坏攻击。 我们将检查两个 Solodit 清单项(SOL-AM-GA-1 和 SOL-AM-GA-2),重点介绍恶意破坏者如何破坏协议。
为了获得最佳体验,请打开一个包含 Solodit 清单 的选项卡以供参考。
让我们清楚地了解我们正在讨论的攻击类型。 在恶意破坏攻击中, 恶意行为者的目的是中断或阻止合法用户执行所需的功能。 攻击者通常会产生费用(例如 gas 费),而不会从破坏性行为中获得直接的经济利益。
术语 “恶意破坏” 可能源于在线游戏社区,它描述了故意激怒和骚扰其他玩家的玩家,他们经常为了娱乐而不是战略优势而打破游戏的预期流程。 同样,在智能合约的上下文中,恶意破坏攻击优先考虑破坏和烦扰 而不是利润。
值得注意的是,在 web3 安全领域中,术语 “恶意破坏” 和 “拒绝服务(DoS)” 有时可以互换使用,这可能会造成混淆。 但是,了解它们之间的差异可以阐明每种攻击背后的意图。
DoS 攻击是一个源于通用网络和计算机安全的术语。 它旨在使服务或网络暂时或永久地对所有用户不可用。 这可能涉及用流量淹没系统或利用阻止所有人进行合法访问的漏洞。 其目标通常是对整个服务进行广泛的破坏。
因此,范围和意图是两种攻击类型之间的主要区别。 但是,界限有时可能会变得模糊,这意味着某些漏洞可能属于任何一类。 一次有影响力的恶意破坏攻击可能会导致一部分用户或功能的 DoS 情形。
最终,精确的分类不如识别漏洞、了解其影响以及实施有效的缓解措施重要。 因此,当我们浏览清单时,你可能会注意到主题上的一些重叠或被不同分类的项目之间的相似之处。
我们今天讨论的两个清单项都属于 恶意破坏攻击 类别,展示了攻击者用来破坏协议的不同技术,而不需要寻求直接的经济利益。
描述:恶意行为者可以通过稍微更改链上 状态 来阻止常规用户交易。
补救措施:确保正常用户操作,尤其是像提款和还款这样的重要操作,不受其他行为者的干扰。
此检查的重点是防止攻击者通过更改受害者函数使用的共享状态 变量 来阻止受害者的基本操作的情况。
如果合约允许任何人修改另一个用户依赖的关键操作(例如提款条件、标志或时间戳)的状态变量,则攻击者可以恶意地更改该状态以阻止受害者。 攻击者专门针对受害者取得进展的能力,通常会花费 gas 来做到这一点。 这在交易费用低的链上尤其可行。
考虑这个具有时间延迟提款功能的 VulnerableVault
合约。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableVault {
uint256 public delay = 60 minutes;
mapping(address => uint256) public lastDeposit;
mapping(address => uint256) public balances;
function deposit(address _for) public payable {
lastDeposit[_for] = block.timestamp;
balances[_for] += msg.value;
}
function withdraw(uint256 _amount) public {
require(block.timestamp >= lastDeposit[msg.sender] + delay,
"Wait period not over");
require(balances[msg.sender] >= _amount, "Insufficient funds");
balances[msg.sender] -= _amount;
(bool success,) = payable(msg.sender).call{value: _amount}("");
require(success, "Transfer failed");
}
}
deposit(address _for)
函数是弱点。 它允许调用者(msg.sender
)指定任何 地址 _for
。 调用此函数时,它会更新 lastDeposit[_for]
。 攻击者可以调用此函数,将受害者的地址指定为 _for,并发送少量金额(例如,1 wei)。 此操作会重置受害者的 lastDeposit
时间戳。
由于 withdraw
函数检查 lastDeposit[msg.sender]
,因此攻击者的操作会直接阻止受害者(当他们在 withdraw
调用中是 msg.sender
时)满足时间条件。 攻击者通过重置他们的提款计时器来激怒受害者。
概念验证:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
contract GriefingAttackTest is Test {
VulnerableVault public vault;
address public alice = address(1); // Victim
address public attacker = address(2); // Griefer
function setUp() public {
vault = new VulnerableVault();
vm.deal(alice, 1 ether);
vm.deal(attacker, 0.1 ether); // Attacker only needs gas money
}
function testGriefingAttack() public {
// Alice deposits
vm.prank(alice);
vault.deposit{value: 1 ether}(alice);
// Fast forward time to simulate the delay passing
vm.warp(block.timestamp + 60 minutes + 1 seconds);
// Attacker resets Alice's timer
vm.prank(attacker);
vault.deposit{value: 1 wei}(alice);
// Alice tries to withdraw but fails
vm.prank(alice);
vm.expectRevert("Wait period not over");
vault.withdraw(1 ether);
}
}
补救措施:阻止用户修改属于其他用户的关键状态变量。 这里最简单的解决方案是限制 deposit
函数仅更新调用者(msg.sender
)的状态。
// Corrected deposit function (inside VulnerableVault)
function deposit() public payable {
// Only affects the caller's state
lastDeposit[msg.sender] = block.timestamp;
balances[msg.sender] += msg.value;
}
现在,只有 Alice 可以更新 lastDeposit[alice]
。
描述:攻击者可以提供经过仔细计算的 gas 量来强制执行合约中的特定执行路径,从而以意想不到的方式操纵其行为。
补救措施:在关键操作之前实施显式的 gas 检查。
此项的重点是 攻击者可以通过精确控制提供给交易的 gas 来操纵合约执行流程的漏洞。 通过为某些步骤提供足够的 gas,而为其他步骤则不够,攻击者可能会绕过检查,使合约处于不一致的状态,或导致操作有选择地失败。 这会导致拒绝服务或其他意外结果。
这种操纵的一个主要例子是外部调用期间的 gas 不足恶意破坏。 虽然清单补救措施提到了 “显式的 gas 检查”,通常指的是 require(gasleft() > MIN_GAS_NEEDED)
,但这种涉及外部调用的特定攻击最好以不同的方式缓解。
在这种情况下,攻击者利用一个调用外部合约但未验证外部调用是否成功的合约。 攻击者精心设计了一个交易,提供了足够的 gas 来执行调用合约的逻辑,直到 外部调用,甚至可能提前更新某些状态,但没有足够的 gas 来让外部调用成功完成。 如果调用合约未检查成功状态,则可能会完成其执行,认为一切正常,从而使内部记录与失败的外部交互的现实不符,从而使系统处于不一致的状态。
让我们用一个 Relayer
合约示例来说明:
Relayer
合约的 forward
函数旨在通过对 Target
合约的外部调用来执行操作。
至关重要的是,forward
在进行外部调用或确认其成功之前,执行内部状态更新(将请求 _data
标记为 executed
以进行重放保护)。
该函数未能检查 target.call(...)
返回的成功状态。
一名恶意破坏者调用 forward
,提供了一个经过仔细计算的 gas 限制:足以进行 require
检查和 executed[_data] = true
更新,但gas 不足,无法让后续的 target.call(...)
在 Target
合约中完全成功。
target.call
耗尽 gas 并静默失败(从 Relayer
的角度来看,因为未检查其成功)。 但是,forward
函数 “成功” 完成。
结果是不一致的状态:Relayer
合约的 executed
映射表明该操作已完成,但 Target
合约中必要的外部工作从未发生。 由于重放保护,合法用户的特定交易(_data
)现在被永久阻止,这实际上是被利用 gas 机制且缺乏错误检查的攻击者审查的。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
// Target contract with a function that consumes some gas
contract Target {
uint256 private _storedData = 0;
function execute(bytes memory _data) external {
// Simulate some work that consumes gas
uint256 i;
while(i < 100) {
i++;
_storedData += i;
}
}
}
// Relayer contract vulnerable to insufficient gas griefing
contract Relayer {
mapping (bytes => bool) public executed; // Replay protection mapping
address public target;
constructor(address _target) {
target = _target;
}
function forward(bytes memory _data) public {
// Check replay protection
require(!executed[_data], "Replay protection");
executed[_data] = true;
// Vulnerability: External call is made, but its success status is NOT checked.
// If target.call runs out of gas (due to limited gas sent by attacker),
// this function DOES NOT revert. The state change above persists.
target.call(abi.encodeWithSignature("execute(bytes)", _data));
}
}
漏洞解释:Relayer.forward
函数在调用 Target
之前将 _data
标记为 executed
,并且至关重要的是不检查 target.call
的 success
返回值。 攻击者通过发送一个交易来利用这一点,该交易具有足够的 gas 来执行 require
和 executed[_data] = true
行,但没有足够的 gas 来让 target.call
完成其内部逻辑。 由于 gas 耗尽,外部调用失败,但 Relayer
交易继续并成功完成,因为未检查失败。 这使得 executed
标志为 true,从而破坏了状态并阻止实际的 _data
成功中继,即使 Target.execute
函数在攻击交易中从未运行其过程。
概念验证:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
// Assume Target and Relayer contracts are defined above or imported
contract RelayerTest is Test {
Target target;
Relayer relayer;
address maliciousForwarder;
bytes testData;
function setUp() public {
target = new Target();
relayer = new Relayer(address(target));
maliciousForwarder = makeAddr("maliciousForwarder");
testData = abi.encode("user_transaction");
// Fund the malicious forwarder
vm.deal(maliciousForwarder, 1 ether);
}
function testInsufficientGasGriefing() public {
// Check how much gas is needed to execute the target contract
uint256 gasBefore = gasleft();
bytes memory tempData = abi.encode("gas_test");
target.execute(tempData);
uint256 gasAfter = gasleft();
uint256 gasNeeded = gasBefore - gasAfter;
console.log("Gas needed to execute target contract:", gasNeeded);
// First, verify that the data hasn't been executed yet
assertEq(relayer.executed(testData), false);
// Malicious actor calls the forward function with precisely crafted gas amount
// The actor deliberately calculates just enough gas for the relayer to mark
// the transaction as executed but not enough for the external call to succeed
vm.prank(actor);
// We use a specific low gas limit to demonstrate the attack
// The actor carefully crafts this value to trigger the unexpected case
uint256 limitedGas = gasNeeded - 10000;
// Call the forward function with limited gas
(bool success, ) = address(relayer).call{gas: limitedGas}(
abi.encodeWithSignature("forward(bytes)", testData)
);
// The top-level call should succeed even though the external call failed
assertTrue(success, "Top-level call should succeed");
// Verify that the data is now marked as executed
assertTrue(relayer.executed(testData), "Data should be marked as executed");
// Now if a legitimate user tries to submit the same transaction, it will be rejected
vm.expectRevert("Replay protection");
relayer.forward(testData);
}
}
补救措施:虽然一般指导建议 “显式的 gas 检查”,但对于特定漏洞(通过 gas 不足以进行 外部调用 来操纵执行流程),最直接和最可靠的修复方法是检查外部调用的成功状态。
底层 .call
返回一个布尔值 success
。 必须检查此值。 如果 success
为 false
(如果外部调用 reverts 或耗尽 gas,则会发生这种情况),则调用函数应回退,通常使用 require(success, "Error message")
。 这可以防止交易以不一致的状态成功。
在外部调用之前使用 require(gasleft() > MIN_GAS_NEEDED)
通常对于这个特定问题不太有效,因为外部合约实际消耗的 gas 可能难以预测或被恶意目标故意膨胀。 require(success)
检查可以正确处理结果,无论失败的原因是什么。
// Corrected forward function snippet (inside Relayer)
function forward(bytes memory _data) public {
require(!executed[_data], "Replay protection");
executed[_data] = true;
// Interaction: Make the external call
(bool success, ) = target.call(abi.encodeWithSignature("execute(bytes)", _data));
require(success, "External call failed");
}
这个修改后的逻辑确保交易只有在 target.call
真正成功时才完成并将 executed
标记为 true
。 通过在外部调用失败时回退整个交易,保持状态一致性,它可以直接防止所示的 gas 操纵攻击。
所有示例都可以在我的 GitHub 上 找到。
恶意破坏攻击强调,并非所有 区块链 攻击都旨在直接盗窃。 有时,目标仅仅是破坏。 通过了解恶意破坏者愿意承担成本来给他人带来不便或阻止他人,我们可以更好地预测漏洞。
开发安全合约需要一种 对抗性的视角:
始终询问:一个用户的操作(即使看起来不合理或代价高昂)是否会阻止另一个合法用户按预期使用协议?
盘问状态更改:谁控制着关键状态? 是否可以以不公平地阻止他人的方式进行更改?
验证外部交互:我的合约是否假定外部调用成功? 如果它们失败(由于 gas 耗尽或其他原因)会发生什么? 状态是否仅在必要的外部操作确认成功后才更新?
通过提出这些问题并利用像 Solodit 这样的清单,开发人员可以构建更强大的系统,从而抵抗恶意破坏攻击。
请继续关注下一期 “Solodit 清单解释!
- 原文链接: cyfrin.io/blog/solodit-c...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!