Anatomy of a Hack: How `sol-sentry` Prevents Reentrancy Attacks

The reentrancy attack is one of the most devastating and infamous vulnerabilities in smart contract history, responsible for hundreds of millions of dollars in losses. It's a subtle bug where an attacker's contract can call back into your contract and drain funds before your code has finished its initial execution.

In this post, we'll dissect a classic example of this vulnerability and show how sol-sentry provides an instant, automated defense against it.

The Vulnerable Code: A Simple Vault

Let's look at a simplified contract that holds user balances. It has a deposit function and a withdraw function. Can you spot the flaw?


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableVault {
    mapping(address => uint) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        uint userBalance = balances[msg.sender];
        require(userBalance > 0, "No balance to withdraw");

        // THE FLAW: External call is made BEFORE the state update
        (bool success, ) = msg.sender.call{value: userBalance}("");
        require(success, "Transfer failed.");

        // State update happens last, which is too late
        balances[msg.sender] = 0;
    }
}
                    

The vulnerability lies in the order of operations. The contract sends ETH to the user before it sets their balance to zero. This tiny mistake opens the door for a catastrophic exploit.

The Attack Explained

An attacker wouldn't call withdraw() from a regular wallet. They would write another smart contract to perform the attack in a few simple steps:

  1. The attacker deploys a malicious contract (`Attacker.sol`).
  2. Inside `Attacker.sol`, they write a special `receive()` function. This function's code will automatically run anytime the contract receives ETH.
  3. The attacker programs the `receive()` function to call VulnerableVault.withdraw() again if there's still gas available.
  4. The attacker calls their own contract, which deposits 1 ETH into the vault and then calls withdraw() for the first time.

The result is a vicious loop: The vault sends 1 ETH, triggering the attacker's `receive()` function, which calls `withdraw()` again. Since the vault hasn't updated the balance yet, it happily sends another 1 ETH, and the cycle repeats until the vault is completely drained.

The Automated Defense: sol-sentry

Manually spotting this requires a trained eye and disciplined adherence to security patterns. Forgetting the correct order just once can be a fatal mistake. This is where automated analysis becomes your most critical line of defense. Running sol-sentry on this contract provides an immediate, unambiguous warning:


$ sol-sentry scan ./VulnerableVault.sol

🚨 Found 1 issues in contracts/VulnerableVault.sol

[CRITICAL] Potential Reentrancy Vulnerability
- Function:     withdraw()
- Line:         16
- Details:      An external call is made to `msg.sender` before the state variable `balances` is updated. This violates the Checks-Effects-Interactions pattern and can be exploited.

❌ This project is NOT safe to deploy.
                    

Without needing to understand the complex attack vector, `sol-sentry` instantly identifies the root cause: an interaction (the external call) happens before an effect (the state change). This simple, automated check would have prevented the hack before it ever had a chance.

The Fix: Checks-Effects-Interactions

The solution is to always follow the Checks-Effects-Interactions (CEI) pattern. First, check conditions. Second, update state (the effect). Third, and only as the very last step, interact with other contracts.


    function safeWithdraw() public {
        // 1. Checks
        uint userBalance = balances[msg.sender];
        require(userBalance > 0, "No balance to withdraw");

        // 2. Effects (Update state FIRST)
        balances[msg.sender] = 0;

        // 3. Interactions (External call is LAST)
        (bool success, ) = msg.sender.call{value: userBalance}("");
        require(success, "Transfer failed.");
    }
                    

Conclusion

While reentrancy is a complex attack, preventing it comes down to a simple, repeatable pattern. But developers are human, and mistakes happen. Automated tools like sol-sentry aren't just convenient; they are an essential part of a professional security workflow, acting as a tireless sentry that never forgets to check for the patterns that lead to exploits.