Back to blogs
Written by
Hans
Published on
April 14, 2025

Solodit Checklist Explained (5): Griefing Attacks

Learn how to prevent griefing attacks in smart contracts with real examples and fixes from the Solodit checklist. Build resilient blockchain protocols.

Table of Contents

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.

Understanding griefing and denial-of-service (DoS)

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.


SOL-AM-GA-1: Is there an external function that relies on states that can be changed by others?

  • Description: Malicious actors can prevent regular user transactions by making a slight change to the on-chain states.
  • Remediation: Ensure normal user actions, especially important actions like withdrawal and repayment, are not disturbed by other actors.

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].

SOL-AM-GA-2: Can contract operations be manipulated with precise gas limit specifications?

  • Description: Attackers can supply carefully calculated gas amounts to force specific execution paths in the contract, manipulating its behavior in unexpected ways.
  • Remediation: Implement explicit gas checks before critical operations.

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:

  1. The Relayer contract's forward function is designed to execute an action via an external call to a Target contract.
  2. Crucially, 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.

  3. The function fails to check the success status returned by the target.call(...).

  4. A griefer calls 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.

  5. The 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."

  6. The result is an inconsistent state: the 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. ‍

Conclusion

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:

  • Always ask: Can one user's action, even if seemingly irrational or costly, prevent another legitimate user from using the protocol as intended?
  • Interrogate state changes: Who controls the critical state? Can it be changed in a way that blocks others unfairly?

  • Validate external interactions: Does my contract assume success for external calls? What happens if they fail (due to out-of-gas or other reasons)? Is the state updated only after necessary external operations are confirmed successful?

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!"

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.