Skip to main content

Command Palette

Search for a command to run...

Smart Contract Security Checklist 3️⃣ — (Attacker’s Mindset)

Question: Can forcing the protocol to process a queue lead to DOS?

Updated
14 min read
Smart Contract Security Checklist 3️⃣ — (Attacker’s Mindset)
F

smart contract developer

That question is straight from the Solodit Smart Contract Security Checklist. And like most questions on that checklist, it sounds simple enough until you start pulling at the thread.

Hello again. Welcome back if you’ve been following this series, and welcome in if this is your first time here.

Today we are going deeper into a class of attack that does not get enough attention in beginner security content: gas griefing. Specifically, how an attacker can weaponize the way your protocol handles queues, arrays, and loops to either drain the protocol’s operating budget, block legitimate users, or make it impossible to slash a misbehaving validator.

We will be using two real audit findings as our anchors:

  1. The AutoRange bot griefing vulnerability from the Revert Finance audit (via Code4rena).

  2. The verifyDoubleSigning denial-of-slashing vulnerability from the Ethos EVM audit (via OtterSec).

Both involve the same root problem, just in very different contexts. By the end of this article, you will know how to spot it, why it keeps appearing in audits, and what you can actually do about it.

Let’s get into it.

What Is Gas Griefing, Really?

Gas griefing is when an attacker forces someone else to spend more gas than expected, usually without that person realizing it until their transaction reverts or becomes too expensive to run.

It is not the same as a direct theft. No tokens move to the attacker’s wallet. What moves instead is cost. The attacker pays a little; the victim (or protocol) pays a lot. And that asymmetry is the whole point.

There are a few ways this shows up in smart contracts:

  • An unbounded loop that an attacker can grow artificially

  • A callback function (like onERC721Received) that the attacker controls and can weaponize

  • A queue that has no limit on how many items can be pushed into it

In both audit findings we are covering today, the attack hits different parts of a system but exploits the same fundamental design gap: the protocol trusted that some part of its execution flow would behave honestly, and it did not account for what happens when it does not.

Part One: The AutoRange Bot Gets Gas Griefed

What Was AutoRange Doing?

Revert Finance’s AutoRange contract lets users configure a position so that when certain conditions are met (the position goes out of range), a protocol-operated bot will automatically adjust that position. The bot is set as an operator and calls execute() or executeWithVault() when it detects that a position is ready.

When execute() runs, it mints a new NFT and sends it to the user using NPM::safeTransferFrom(). That function is part of the ERC721 standard, and it includes a safety check: if the recipient is a contract, it calls onERC721Received() on that contract. This is how ERC721 ensures contracts are actually prepared to receive NFTs.

That callback is where the attack lives.

The Attack

Here is how a malicious user exploits this:

  1. They deploy a contract with a poisoned onERC721Received() implementation.

  2. They send their NFT to this contract.

  3. They call AutoRange::configToken() to register this position for auto-ranging.

  4. They wait.

When the bot detects that the position meets the trigger conditions, it calls execute(). execute() mints the new NFT and tries to send it to the user’s malicious contract. The onERC721Received() callback fires, and instead of returning the expected selector, it does this:

function onERC721Received(

    address operator,

    address from,

    uint256 tokenId,

    bytes calldata data

) external override returns (bytes4) {

    uint256 initialGas = gasleft();

    uint256 counter = 0;

    uint256 remainingGasThreshold = 5000;

    while (gasleft() > remainingGasThreshold) {

        counter += 1;

    }

    revert("Consumed the allotted gas");

}

The loop burns through all the remaining gas. Then it reverts. The whole transaction fails.

The bot paid for that gas. The protocol gets nothing from it because execute() never completed. And the attacker’s position config is still sitting in positionConfigs, because that mapping is only cleared at the very end of a successful execute() call.

So the next time the bot detects the same position meets the trigger criteria, it calls execute() again. Gets griefed again. Pays gas again. Receives nothing again.

This is not a one-time cost. It is a recurring tax on the protocol.

Why Could This Not Just Be Monitored?

The Revert team’s response was essentially: “We have off-chain monitoring, we simulate transactions before running them, and we can identify and skip malicious positions.”

That is a reasonable operational mitigation. But the warden raised a valid follow-up: you can only detect the griefing after the first transaction fails. You cannot prevent that first loss. And if an attacker registers multiple positions with multiple trigger parameters across multiple accounts, each one gets at least one free grief before you notice.

More importantly, the position config stays alive in state. There is no on-chain function to remove it other than a successful execute(). The only way to truly stop it is to remove the bot as an operator and shut down the AutoRange functionality entirely, which is a real DOS of a core protocol feature.

The judge settled on Medium severity, not because the attack is unrealistic, but because the off-chain monitoring makes it non-persistent in practice. The warden pushed back, and reasonably so. The truth is somewhere in between: this is a real, recurring drain with no clean on-chain fix.

What This Looks Like from an Attacker’s Perspective

The cost to the attacker: gas to deploy a contract + gas to call configToken(). That’s it.

The cost to the protocol: gas for every execute() call that gets griefed, multiplied by however many positions the attacker has registered, multiplied by how often the trigger conditions are met.

The attacker does not even need an open position or a vault loan. They just need an NFT and a malicious contract. This is why the warden described it as “very inexpensive.”

Part Two: Denial-of-Slashing in verifyDoubleSigning

The Setup

This one is from the Ethos EVM audit, conducted by OtterSec. Ethos is a restaking protocol that lets validators on Cosmos chains use their staked ETH as collateral. Part of the security model involves slashing validators who double-sign blocks, which is a serious misbehavior in proof-of-stake systems.

The function responsible for detecting and punishing double-signing is verifyDoubleSigning().

function verifyDoubleSigning(

    address operator,

    DoubleSigningEvidence memory e

) external {

    // ...

    for (uint256 i = 0; i < delegatedValidators.length; i++) {

        if (

            EthosAVSUtils.compareStrings(

                delegatedValidators[i].validatorPubkey,

                e.validatorPubkey

            ) &&

            isDelegationSlashable(delegatedValidators[i].endTimestamp)

        ) {

            timestampValid = true;

            stake = EthosAVSUtils.maxUint96(stake, delegatedValidators[i].stake);

        }

    }

    // ...

}

It loops over all entries in delegatedValidators looking for evidence of double-signing. The length of that array is N. So the function’s gas cost is O(N): the longer the array, the more gas it takes to run.

The Attack

An operator who wants to escape slashing can call updateDelegation() repeatedly with the same operator and consumer chain. Each call appends a new entry to delegatedValidators. The cost of calling updateDelegation() is constant, meaning it does not get more expensive as the array grows.

So the attacker just keeps calling it. Over and over. Until delegatedValidators is so long that iterating over it in a single transaction would require more gas than the block limit allows.

On Ethereum mainnet, the block gas limit is currently 30 million. Once a single loop would consume more than that, verifyDoubleSigning() cannot physically complete. No transaction can process it. The evidence of double-signing exists, but it can never be verified on-chain.

The attacker has made themselves unslashable. Not by hiding their misbehavior. Not by exploiting some cryptographic weakness. Just by making the array too long to iterate.

This is a more severe variant of the same root pattern. Where AutoRange was about financial griefing, this one is about breaking a security guarantee. The protocol’s ability to punish bad actors depends entirely on a function that the bad actor can permanently disable.

What Makes This Especially Dangerous

With AutoRange, the worst case is a recurring gas drain and a temporary DOS of an automation feature. Painful, but not existential.

With verifyDoubleSigning, the worst case is that validators can double-sign freely with no consequence, because the slashing mechanism is permanently broken for their address. That undermines the entire economic security model of the protocol.

The attack is also quiet. The attacker does not do anything obviously malicious when they call updateDelegation() repeatedly. It looks like normal protocol activity. By the time anyone notices and tries to slash them, the array is already too long.

The Fix and the Follow-Up Problem

OtterSec’s recommended mitigations were:

  • Enforce a hard cap on the length of delegatedValidators (they suggested 100).

  • Add a public function to clear old entries from the array.

The team patched it and implemented both. But here is where it gets interesting.

The cap created a new attack surface: now an attacker could deliberately fill the delegatedValidators array to the cap limit, blocking any new legitimate delegation from being added. So the fix introduced a griefing vector of a different kind.

The team’s follow-up solution was to change what gets stored in the array. Instead of storing the actual ETH amount, they stored the percentage of the delegation. This changed the structure enough to make the griefing-via-cap attack impractical.

This is a great example of why security is iterative. You fix one thing, you might open another. The goal is not to find one perfect fix, but to keep narrowing the attack surface until the residual risk is acceptable.

The Pattern Behind Both Vulnerabilities

Let’s step back and look at what these two findings have in common, because they are both instances of the same underlying problem.

In AutoRange: the protocol relied on an external callback during a critical operation. The callback was user-controlled. No gas limit was set on it. An attacker could use it to burn all available gas and revert the transaction.

In verifyDoubleSigning: the protocol used an unbounded loop over a user-growable array. The cost to grow the array was constant. The cost to loop over it was linear. An attacker could grow it until the loop exceeded the block gas limit.

Both attacks are about asymmetric cost. The attacker’s actions are cheap. The protocol’s response is expensive, or eventually impossible. And in both cases, the design did not account for adversarial behavior in what looked like normal protocol interactions.

This is the attacker’s mindset in practice. You are not looking for a bug in the code. You are looking for a place where the design assumes cooperation, and then asking: what happens when someone does not cooperate?

How to Think About This as a Developer or Auditor

When you are reading a smart contract and you see any of the following, slow down:

External calls to user-controlled contracts during execution

If your protocol calls a function on an address that the user provided, and that function runs before your state updates are complete, you have a potential griefing or reentrancy surface. Always ask: what can the worst implementation of this function do? Can it consume all gas? Can it revert? What state would that leave you in?

Loops over arrays that users can modify

If users can push items into an array, and somewhere else in the protocol a function loops over that entire array, you have a potential DOS surface. The question to ask is: what is the maximum number of items this array can hold? Is there a limit? Who enforces it? What happens when the loop hits the block gas limit?

State that only gets cleared on successful completion

In the AutoRange case, the position config was only removed at the end of a successful execute(). That meant a failed execution left the config in place, ready to trigger another attempt. If your state cleanup depends on a transaction completing successfully, and there is any way for the transaction to fail without that cleanup running, you have a persistent state leak.

Off-chain mitigation for on-chain risk

Both audit responses leaned on off-chain monitoring as part of the mitigation. That is sometimes reasonable, but it should never be the primary defense. Off-chain systems can fail, be delayed, or have blind spots. The on-chain contract should be defensible on its own merits. If your only answer to a griefing vector is “our bots will detect it,” that is worth flagging.

Mitigations Worth Implementing

For callback-based griefing (like AutoRange):

Use a pull pattern instead of push. Instead of minting the new NFT directly to the user inside execute(), mint it to the protocol contract first. Then let the user call a separate function to claim it. This removes the external callback entirely from the critical execution path.

// Instead of:

npm.safeTransferFrom(address(this), owner, newTokenId);

// Do this:

pendingClaims[owner] = newTokenId;

// User calls claim() separately

Set explicit gas limits on external calls. If you must call an external contract, use a low-level call with a gas stipend. Do not let an untrusted callback consume everything.

bool success, ) = target.call{gas: 50000}(data);

Be careful here though. You need enough gas for legitimate use cases. Tune this carefully.

For unbounded loop griefing (like verifyDoubleSigning):

Enforce array length limits. Pick a number that is high enough to cover all legitimate use cases and low enough that the loop will always fit within reasonable gas bounds. Document why you chose that number.

require(delegatedValidators.length < MAX_DELEGATIONS, “Delegation limit reached”);

Use mappings instead of arrays where possible. If you need to check whether a specific key exists, a mapping is O(1). An array loop is O(N). If you are iterating to find a match, consider whether a mapping lookup would serve the same purpose.

Design cleanup paths. If your array can grow, you need a way to shrink it. This could be automatic (delete entries when they expire or become irrelevant) or manual (a function that authorized parties can call). Make sure the cleanup function itself cannot be griefed.

Paginate large operations. If you genuinely need to loop over a large dataset, do not try to do it all in one transaction. Let callers specify a start index and a count, and process in chunks.

function verifyDoubleSigning(

    address operator,

    DoubleSigningEvidence memory e,

    uint256 startIndex,

    uint256 batchSize

) external {

    uint256 end = startIndex + batchSize;

    if (end > delegatedValidators.length) end = delegatedValidators.length;

    

    for (uint256 i = startIndex; i < end; i++) {

        // process

    }

}

Proof of Concept Thinking

When you are auditing a contract, do not just read it. Simulate the adversarial scenario in your head (or in code).

For callback griefing:

What is the most expensive thing a user-controlled callback can do? Can I write a contract that burns all gas and reverts? What state does that leave the calling contract in? Does the calling contract have any way to recover?

For unbounded loops:

What is the maximum size this array can reach? Is there a cap? If not, how many insertions would it take to push the loop past the block gas limit? How much would those insertions cost? Is that affordable for a motivated attacker?

For persistent state:

Is there any state that only gets cleaned up on successful completion? What happens to that state if the transaction fails? Can an attacker deliberately trigger a failure to keep the state alive?

If you can answer these questions with a plausible attack scenario, that is a finding worth reporting.

Recap

Gas griefing attacks do not always look dramatic. There is no exploit contract draining a liquidity pool. No flash loan. No reentrancy loop. Sometimes it is just a contract that burns gas and reverts, or an array that keeps growing until a loop becomes physically impossible to complete.

But the impact can be severe. A protocol that cannot execute its own bot functions loses money on every failed transaction. A slashing mechanism that can be permanently disabled by its own target is not really a slashing mechanism at all.

The checklist question we started with, “Can forcing the protocol to process a queue lead to DOS?”, is one specific framing of a much broader concern: does your protocol’s execution path depend on external behavior it cannot control?

If the answer is yes, you need to think carefully about what the worst version of that external behavior looks like, and whether your contract survives it.

Always validate assumptions. Always cap what users can grow. Always ask: what does this look like when someone is actively trying to break it?

See you in the next one.

Sources: