DEV Community

Cover image for Thinking Like an Attacker: The Airbags and Seatbelts of Smart Contract Security
Obinna Duru
Obinna Duru

Posted on

Thinking Like an Attacker: The Airbags and Seatbelts of Smart Contract Security

In our last post, we built a mathematical proving ground using Foundry. We used stateful fuzzing to prove that the rules of our MilestoneCrowdfundUpgradeable protocol work exactly as intended.

But testing only proves that the contract behaves correctly when people follow the rules. What happens when someone actively tries to break them?

In Web2, when you think about security, you think about the perimeter. Who can get in? You build firewalls, you require authentication, you set up rate limiting. The attacker is outside the system, trying to break down the door.

In Web3, there is no perimeter. Your contract is public. The state is public. Every single function is readable by anyone on earth the moment you deploy it. The attacker is not trying to get past a wall, they are standing inside the room with you, reading your rulebook, looking for a sentence that contradicts itself.

So, my security-first mindset when I sit down to write Solidity is this: I am writing rules for a system that a brilliant, motivated, financially incentivized adversary will study longer and harder than I wrote it.

In this post, I want to show you exactly how I design against those adversaries. We are going to look at the most infamous hack in Web3 history, how to prevent it using the golden rule of smart contracts, and the real-world edge cases I had to actively design around.

The Refund Kiosk Glitch

To understand the most dangerous exploit in smart contracts, you don't need to understand code yet. You need to understand the refund kiosk glitch.

Imagine a store installs a new, automated self-service refund kiosk. It works like this:

  1. You scan your receipt.
  2. The machine dispenses your cash.
  3. The machine marks your receipt as "refunded" in the database.

A clever person notices something. Between step 2 and step 3, there is a processing gap: a brief moment while the database updates.

So, the attacker builds a device and physically attaches it to the kiosk's cash dispenser slot. When cash drops into the tray, the weight of the notes triggers a pressure sensor inside the device, which automatically scans the receipt again immediately.

The attacker does not press any buttons. The act of receiving cash is itself the trigger for the next scan.

The kiosk checks the database: "Is this receipt already refunded?" The database still says no, because step 3 hasn't happened yet. So the kiosk dispenses again. Cash drops. The pressure sensor fires. The receipt scans again. The database still says no. It dispenses again.

Attack Scenario: Refund Glitch Exploit

This loop continues until the machine is completely empty. Nobody held anyone at gunpoint. The machine was following its own rules perfectly, it checked the ledger every single time before it paid. It just checked a ledger that was always one step behind reality.

The fix is incredibly simple. You just change the order of operations at the kiosk:

  1. Scan your receipt.
  2. Mark it as refunded in the database immediately.
  3. Now, dispense the cash.

Now, when the cash drops and the pressure sensor fires a second scan, the kiosk checks the database, sees it is already refunded, and dispenses nothing. The loop never starts. By updating the internal record before handing over the cash, we close the exploitation gap entirely.

In smart contract engineering, this fix is called Checks-Effects-Interactions (CEI).

  • Check: Does this user have a valid claim?
  • Effect: Zero their balance in the ledger right now, before a single coin moves.
  • Interaction: Now, send the money.

Reentrancy: The Kiosk on Ethereum

Now that you understand the kiosk glitch, you already understand Reentrancy. Because Reentrancy is exactly that glitch, running on a blockchain.

In Ethereum, when your smart contract sends ETH to an address, if that address belongs to another smart contract, it can execute code the exact moment it receives the ETH. That receiving code is called a receive() function.

That receive() function is the pressure sensor. The attacker writes it once, deploys their contract, and the blockchain executes it automatically the moment ETH arrives. They do not manually trigger anything. The callback is a feature of how ETH transfers work, turned into a weapon.

Here is what the attacker's contract looks like:

contract Attacker {
    MilestoneCrowdfund public target;

    // The pressure sensor: When this contract receives ETH, 
    // it immediately calls claimRefund again, before the first call finishes!
    receive() external payable {
        target.claimRefund(campaignId);
    }

    function attack() external {
        target.claimRefund(campaignId);
    }
}
Enter fullscreen mode Exit fullscreen mode

If we sent the money before updating our internal ledger, this attacker would drain our entire protocol in a single transaction. But because we use the Checks-Effects-Interactions pattern, look at the exact order of the claimRefund function inside MilestoneCrowdfundUpgradeable:

// 1. CHECKS: Does the user have a valid claim?
uint256 userPledge = _pledges[_id][msg.sender];
if (userPledge == 0) revert MilestoneCrowdfund__NoContribution();

// 2. EFFECTS: Update the ledger first, before money moves!
_pledges[_id][msg.sender] = 0;

// 3. INTERACTIONS: Now the money moves.
(bool success,) = payable(msg.sender).call{value: refundAmount}("");
Enter fullscreen mode Exit fullscreen mode

By zeroing _pledges[_id][msg.sender] before the ETH moves, every malicious reentrant call finds a zero balance and reverts immediately. The ledger is never one step behind.

As a second line of defense, I also use OpenZeppelin's nonReentrant modifier. It works by setting a lock flag at the start of the function. Think of it as a door that locks behind you the moment you step inside. If the malicious receive() function tries to call claimRefund again, it slams into that locked door, and the entire transaction instantly reverts.

CEI makes the contract logically correct. nonReentrant makes it mechanically impossible to reenter. I tell every junior developer: do not choose between them. Use both. CEI is the airbag. nonReentrant is the seatbelt. You want both in the car.

The Real Edge Cases I Had to Design Around

Security isn't just about stopping hackers; it's about mitigating the risks of your own design choices. There were two specific edge cases I had to actively design around.

1. The Live Fee Rate
In MilestoneCrowdfundUpgradeable, the platform fee (defaultFeeBps) is a global variable that the owner can change at any time. This means two donors to the exact same campaign could pay different effective fees if the owner changes the rate between their pledges.

Why design it this way? Because the stakeholder explicitly requested the flexibility to run dynamic fee promotions. I immediately flagged the vulnerability to them: what if a compromised owner key raised the fee to 5% right before a massive whale pledge, and then immediately lowered it back? This would silently skim thousands of dollars from a single donor, and unless you were watching the transaction pool in real-time, it would be almost invisible.

I agreed to the stakeholder's requirement, but only with a strict mitigation strategy. The defense here isn't in the Solidity code, it is in the governance architecture. The owner is strictly documented as an Admin Multisig wallet. A single compromised key cannot change the fee unilaterally. The system around the code must be designed with the same rigor as the code itself.

2. The Dust Sweep
When dividing milestones by percentages, integer division always leaves a tiny fraction of a cent (wei) permanently locked in the contract. To prevent this "dust" from being lost forever, my contract uses a special code path for the final milestone:

if (c.milestonesReleased == c.milestoneCount) {
    amountToRelease = c.totalRaised - c.totalWithdrawn;
}
Enter fullscreen mode Exit fullscreen mode

It simply sweeps whatever is left. But I had to ask myself rigorously: Can this sweep ever release MORE than it should? The mathematical guarantee that it cannot is my invariant: totalWithdrawn <= totalRaised. That is precisely what that same 4,495-second fuzz run from the last post was verifying. The fuzz test isn't decoration; it is the mathematical evidence that this final sweep is safe.

The BinnaDev Takeaway

If you are a junior developer about to deploy your first contract that holds real ETH, here is my ultimate advice to you:

Do not deploy until you can answer this question about every function that sends money: "What happens if the recipient is a malicious contract?"

Not a normal wallet. A smart contract. With a receive() function you did not write, controlled by someone who wants your users' funds, with a pressure sensor already wired and waiting.

If you cannot answer that question confidently for every single external call in your codebase, you are not ready to deploy. Go back and apply the Checks-Effects-Interactions pattern to every function that moves value. Add nonReentrant to every function that moves value. Then ask the question again.

The developers who get hacked are not the ones who don't know about reentrancy. They are the ones who know about it in theory, but did not sit down with their own code and ask that exact question. Knowledge without application is not protection.

Read your own code as if you are the attacker. The moment you find something that makes you uncomfortable as the attacker, you have found something to fix as the engineer. If you want a structured baseline for this evaluation, I highly recommend reading Trail of Bits' excellent post, Can You Pass the Rekt Test? It is a mandatory checklist for any serious Web3 engineering team.

Sleep comes after that review. Not before.

We have architected the protocol, proven the math, and secured the vault. But a secure protocol is useless if no one knows how to safely interact with it. In our fifth and final post, we are going to talk about the most underrated skill in Web3: Writing Audit-Ready Documentation.

Top comments (0)