Welcome back to the "Solodit Checklist Explained" series! We're dissecting the Solodit checklist to empower developers to build secure smart contracts and guide security researchers in identifying vulnerabilities by understanding the attacker's mindset.
Previously, we explored front-running attacks, seeing how malicious actors exploit transaction ordering by monitoring the mempool to preempt or capitalize on your pending actions for their gain. Today, we focus on a category of attacks often driven by malice rather than direct profit: griefing attacks. We'll examine two Solodit checklist items (SOL-AM-GA-1 and SOL-AM-GA-2), highlighting how griefers can disrupt protocols.
For the best experience, open a tab with the Solodit checklist to refer to it.
Let's understand clearly the attack types we're discussing.
In a griefing attack, a malicious actor intends to disrupt or prevent legitimate users from executing desired functions. The attacker often incurs a cost (like gas fees) without gaining direct financial benefit from the disruptive action.
The term "griefing" likely originates from online gaming communities, where it describes players who intentionally irritate and harass others, often breaking the game's intended flow for their amusement rather than strategic advantage. Similarly, in the context of smart contracts, griefing attacks prioritize disruption and annoyance over profit.
It's worth noting that within the web3 security space, the terms "griefing" and "denial-of-service (DoS)" are sometimes used interchangeably, which can be confusing. However, understanding their differences can clarify the intent behind each.
A DoS attack is a term rooted in general network and computer security. It aims to make a service or a network unavailable to all users temporarily or indefinitely. This might involve overwhelming the system with traffic or exploiting vulnerabilities that prevent legitimate access for everyone. The goal is typically widespread disruption of the entire service.
So, the scope and intent are the primary differentiators between the two attack types. However, the line can sometimes blur, which means some vulnerabilities might fit into either category. An impactful griefing attack could potentially lead to a DoS scenario for a subset of users or features.
Ultimately, precise categorization is less critical than identifying the vulnerability, understanding its impact, and implementing effective mitigations. Consequently, as we progress through the checklist, you might notice some overlap in themes or resemblances between items categorized differently.
Both checklist items we discuss today fall under the griefing attack category, showcasing different techniques attackers use to disrupt protocols without necessarily seeking direct financial gain.
This check focuses on preventing situations where an attacker can block a victim's essential actions by changing shared state variables that the victim's function uses.
If a contract allows anyone to modify a state variable that another user depends on for a critical operation (like a withdrawal condition, a flag, or a timestamp), an attacker can maliciously alter that state to block the victim. The attacker specifically targets the victim's ability to progress, often spending gas to do so. This is especially practical on chains with low transaction fees.
Consider this VulnerableVault
contract with a time-delayed withdrawal.
// 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");
}
}
The deposit(address _for)
function is the weakness. It allows the caller (msg.sender
) to specify any address _for
. When this function is called, it updates lastDeposit[_for]
. An attacker can call this function, specifying their victim's address as _for, and send a minimal amount (e.g., 1 wei). This action resets the victim's lastDeposit
timestamp.
Since the withdraw
function checks lastDeposit[msg.sender]
, the attacker's action directly prevents the victim (when they are msg.sender
in the withdraw
call) from satisfying the time condition. The attacker aggravates the victim by resetting their withdrawal timer.
Proof of concept:
// 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);
}
}
Remediation: Prevent users from modifying critical state variables belonging to others. The most straightforward fix here is to restrict the deposit
function to only update the state of the caller (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;
}
Now, only Alice can update lastDeposit[alice]
.
This item focuses on vulnerabilities where an attacker can precisely control the gas supplied to a transaction to manipulate the contract's execution flow. By providing just enough gas for certain steps but not others, attackers can potentially bypass checks, leave the contract in an inconsistent state, or cause operations to fail selectively. This leads to denial of service or other unexpected outcomes.
A prime example of this manipulation is insufficient gas griefing during external calls. While the checklist remediation mentions "explicit gas checks," which often refers to require(gasleft() > MIN_GAS_NEEDED)
, this specific attack involving external calls is most effectively mitigated differently.
In this scenario, an attacker exploits a contract that calls an external contract but fails to verify if that external call succeeded. The attacker crafts a transaction, supplying enough gas to execute the calling contract's logic up to the external call and perhaps even update some state prematurely, but not enough gas for the external call to complete successfully. If the calling contract doesn't check the success status, it may finish its execution thinking everything worked, leaving the system in an inconsistent state where internal records don't match the reality of the failed external interaction.
Let's illustrate with a Relayer
contract example:
Relayer
contract's forward
function is designed to execute an action via an external call to a Target
contract.forward
performs an internal state update (marking the request _data
as executed
for replay protection) before the external call is made or its success confirmed.target.call(...)
.forward
, providing a carefully calculated gas limit: enough for the require
check and the executed[_data] = true
update, but insufficient gas for the subsequent target.call(...)
to fully succeed within the Target
contract.target.call
runs out of gas and fails silently (from the Relayer
's perspective, as its success isn't checked). The forward
function, however, completes "successfully."Relayer
contract's executed
mapping indicates the action was completed, but the necessary external work in the Target
contract never happened. The legitimate user's specific transaction (_data
) is now permanently blocked due to replay protection, which is effectively censored by the attacker who exploited the gas mechanics and lacked error checking.// 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));
}
}
Vulnerability еxplained: The Relayer.forward
function marks the _data
as executed
before calling the Target
and crucially does not check the success
return value of target.call
. An attacker leverages this by sending a transaction with just enough gas to execute the require
and the executed[_data] = true
line but insufficient gas for the target.call
to complete its internal logic. The external call fails due to out-of-gas, but the Relayer
transaction proceeds and finishes successfully because the failure wasn't checked. This leaves the executed
flag true, poisoning the state and preventing the actual _data
from being successfully relayed, even though the Target.execute
function never ran its course in the attack transaction.
Proof of concept:
// 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);
}
}
Remediation: While the general guidance suggests "explicit gas checks," for the specific vulnerability (manipulating execution flow via insufficient gas for an external call), the most direct and robust fix is to check the success status of the external call.
The low-level .call
returns a boolean success
value. This must be checked. If success
is false
(which happens if the external call reverts or runs out of gas), the calling function should revert, typically using require(success, "Error message")
. This prevents the transaction from succeeding with an inconsistent state.
Using require(gasleft() > MIN_GAS_NEEDED)
before the external call is generally less effective for this specific problem, as the actual gas consumed by the external contract can be unpredictable or intentionally inflated by a malicious target. The require(success)
check correctly handles the outcome regardless of the reason for failure.
// 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");
}
This revised logic ensures the transaction only completes and marks executed
as true
if the target.call
genuinely succeeded. It directly prevents the gas manipulation attack shown by reverting the entire transaction upon external call failure, maintaining state consistency.
All examples are available on my GitHub here.
Griefing attacks highlight that not all blockchain exploits aim for direct theft. Sometimes, the goal is simply disruption. By understanding the griefer's willingness to incur costs to inconvenience or block others, we can better anticipate vulnerabilities.
Developing secure contracts requires an adversarial perspective:
By asking these questions and leveraging checklists like Solodit, developers can build more robust systems that are resistant to griefing attacks.
Stay tuned for the next installment of "Solodit Checklist Explained!"