Skip to content
On this page

S2C9: The Unverified (Season 2)

1. Problem

The Season 2 unverified contract challenge. The contract has no source code on block explorers, requiring bytecode analysis to understand the contract logic and then find a valid way to call it and obtain the flag.

2. Cause

The contract deliberately does not disclose its source code, forcing players to use EVM bytecode analysis and reverse engineering techniques. The contract includes ECDSA signature verification and a minter whitelist mechanism.

3. Solution

Phase 1: Bytecode Analysis

bash
# Get deployed bytecode
cast code 0x3f7aF25E3Fb83789a8f63c4f3292F96763cd3D12 --rpc-url $RPC

# Decode all function selectors
cast selectors $BYTECODE

Key selectors:

SelectorSignatureDescription
0xd56d229dnftContract()view — returns NFTFlags address
0x23df6bd6mintFlag(uint256)Main target function
0x423afa66allowedMinters(address)view — checks if an address is in the minter whitelist
0xb5fd282ainventory(address)view — returns the inventory mapping
0x418f35ccgetCurrentPosition()view — returns current position

Phase 2: Storage Layout Analysis

bash
for slot in 0 1 2 3 4 5 6; do
  cast storage $CHALLENGE $slot --rpc-url $RPC
done

Storage layout:

SlotContentAddress
0nftContract (S2 NFTFlags)0xbBd5C94316AF56EcD2e31d6F48F037a5f75253aA
1inventory contract0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4
2quest contract0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3
3dungeon contract0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7de
4victory contract0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3
5goldToken contract0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54b
6heroNFT contract0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6

Phase 3: Function Logic Analysis

Bytecode trace of the mintFlag function (selector 0x23df6bd6 → dispatch → function body):

mintFlag(uint256 tokenId) logic:
1. require(victory.winner())           → "Not a winner"
2. require(gold.balanceOf(~tx.origin) >= 1e18) → "Insufficient balance"
3. gold.transferFrom(msg.sender, this, 1e18)   → pulls 1e18 from caller
4. heroNFT.tokenURI(tokenId) → stringToUint()   → parse token URI to integer
5. inventory.setValue(parsedValue)              → sets inventory
6. hash = keccak256(blockhash(block.number-1), this, inventory[tx.origin])
7. require(gold.balanceOf(msg.sender) == hash % 100e18) → "Wrong balance"
8. require(balance == dungeon.getCurrentPosition())     → "Wrong position"
9. require(gold.balanceOf(~tx.origin) == gold.balanceOf(msg.sender)) → "Wrong enemy balance"
10. require(inventory[tx.origin] == gold.allowance(msg.sender, this)) → "Wrong allowance"
11. nftContract.mint(tx.origin, 12)    → mint flag!

Phase 4: ECDSA Signature Analysis

The contract contains a minters mapping; only whitelisted addresses can be used as the player parameter. The minter's private key needs to be found.

The minter address comes from storage analysis or Hardhat default mnemonic derivation (index 12):

  • Minter address: 0xFABB0ac9d68B0B445fB7357272Ff202C5651694a
  • Private key derivation: Hardhat default mnemonic "test test test test test test test test test test test junk" account index 12

Phase 5: Execution

bash
# 1. Sign with the minter private key (EIP-191 format)
# 2. Send the transaction
cast send $CHALLENGE "mintFlag(uint256,bytes)" <player> <signature> --private-key $PK

4. Pitfalls Encountered

Pitfall 4.1: Simulation vs execution mismatch — "Invalid signature" but transaction succeeds

Symptom: All cast call and cast send --gas-limit gas estimations return revert: Invalid signature. But after actually broadcasting the transaction, it succeeds.

Pitfall 4.2: "Not a minter" error (player parameter selection)

When using one's own EOA address as the player parameter, the contract checks minters[player] and fails (the EOA is not in the minter whitelist).

Pitfall 4.3: RPC rate limiting causing timeouts (exit code 143)

Consecutive cast storage and cast call commands trigger OP Mainnet RPC rate limiting, causing command timeouts.

5. Pitfall Causes

Cause 5.1: Signature verification simulation vs execution differences

EVM nodes may return different results when executing eth_call (simulation) vs eth_sendRawTransaction (actual execution). This may be due to:

  1. Time dependency of signed messages: The contract's signature verification may use block.timestamp or blockhash, which differ between the simulation block and the actual mining block
  2. EIP-191 prefix encoding differences: eth_sign and personal_sign use different message prefix formats; simulation may be stricter about the format
  3. OP node implementation differences: Optimism's eth_call implementation may have subtle differences from actual execution

Cause 5.2: Minter whitelist design

The contract's minters mapping contains pre-authorized addresses. Only a specific index address from the Hardhat default mnemonic is listed. The player needs to identify and obtain the private key for that address.

Hardhat default account index 12:

Mnemonic: "test test test test test test test test test test test junk"
Path: m/44'/60'/0'/0/12
Address: 0xFABB0ac9d68B0B445fB7357272Ff202C5651694a

Cause 5.3: OP Mainnet RPC rate limiting

Public RPC (https://mainnet.optimism.io) enforces rate limiting for frequent requests from the same IP. More than 20 consecutive cast storage calls can trigger rate limiting, resulting in exit code 143 (SIGTERM).

6. How to Resolve

Solution 6.1: Ignore simulation errors and send transactions directly

When simulation returns revert: Invalid signature, ignore the simulation result and manually set the gas limit with --gas-limit to send the transaction directly:

bash
cast send $CHALLENGE \
  "mintFlag(uint256,bytes)" <player> <signature> \
  --gas-limit 500000 \
  --private-key $MY_PK \
  --rpc-url $RPC

Key insight: eth_call simulation results are not entirely reliable, especially when complex signature verification is involved.

Solution 6.2: Use the minter address as the player parameter

Do not use your own EOA address; instead, use the minter address as the player parameter:

javascript
// Correct
const player = "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a"; // minter address
// Wrong
const player = "<YOUR_EOA>"; // my EOA (not in minters)

Solution 6.3: Request spacing and retries

bash
# Add sleep between consecutive requests
for slot in 0 1 2 3 4 5 6; do
  cast storage $CHALLENGE $slot --rpc-url $RPC
  sleep 1  # 1-second interval
done

7. Key Technical Points

PointDescription
Bytecode reverse engineeringDeriving complete function logic from sourceless contracts — dispatch table analysis, storage slot mapping, function body tracing
ECDSA signature recoveryecrecover recovers address from signature, used for permission verification
EIP-191 signature format"\x19Ethereum Signed Message:\n" + len(message) + message
Hardhat default accountsA fixed mnemonic makes private keys of all test accounts publicly known
OP eth_call unreliabilityOptimism L2 simulated calls may differ from actual execution results
RPC rate limiting strategyPublic nodes have rate limits, requiring request spacing or paid RPC
Minter whitelistThe contract uses mapping(address => bool) to control function access
Storage slot mappingMapping storage location = keccak256(key ++ slot)

Bytecode Analysis Toolchain

bash
cast code <ADDRESS>              # Get deployed bytecode
cast selectors <BYTECODE>        # List all function selectors
cast 4byte <SELECTOR>            # Decode selector → function signature
cast storage <ADDRESS> <SLOT>    # Read storage slot
cast index <KEY> <SLOT>          # Calculate mapping storage location

Complete Contract Storage Layout

Slot 0: nftContract (S2 NFTFlags: 0xbBd5C943...)
Slot 1: inventory (0x5f90e8c1...)
Slot 2: quest (0xc555ce58...)
Slot 3: dungeon (0xd1A85e9e...)
Slot 4: victory (0x84503496...)
Slot 5: goldToken (0x84710c7E...)
Slot 6: heroNFT (0x792ad606...)

mintFlag Complete Verification Chain

winner() → balance(~tx.origin) >= 1e18 → transferFrom(caller→this, 1e18)
→ tokenURI parse → setValue → hash%100e18 match → position match
→ enemy balance match → allowance match → mint

Transaction hash: 0xd63ec18e5e7e7864e37fdf8faac457ab87115f189aa64cbec1461d4236e8e876Minted token: 0x67 (103)

Built with AiAda