Appearance
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:
| Selector | Signature | Description |
|---|---|---|
0xd56d229d | nftContract() | view — returns NFTFlags address |
0x23df6bd6 | mintFlag(uint256) | Main target function |
0x423afa66 | allowedMinters(address) | view — checks if an address is in the minter whitelist |
0xb5fd282a | inventory(address) | view — returns the inventory mapping |
0x418f35cc | getCurrentPosition() | 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:
| Slot | Content | Address |
|---|---|---|
| 0 | nftContract (S2 NFTFlags) | 0xbBd5C94316AF56EcD2e31d6F48F037a5f75253aA |
| 1 | inventory contract | 0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4 |
| 2 | quest contract | 0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3 |
| 3 | dungeon contract | 0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7de |
| 4 | victory contract | 0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3 |
| 5 | goldToken contract | 0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54b |
| 6 | heroNFT contract | 0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6 |
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:
- Time dependency of signed messages: The contract's signature verification may use
block.timestamporblockhash, which differ between the simulation block and the actual mining block - EIP-191 prefix encoding differences:
eth_signandpersonal_signuse different message prefix formats; simulation may be stricter about the format - OP node implementation differences: Optimism's
eth_callimplementation 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
| Point | Description |
|---|---|
| Bytecode reverse engineering | Deriving complete function logic from sourceless contracts — dispatch table analysis, storage slot mapping, function body tracing |
| ECDSA signature recovery | ecrecover recovers address from signature, used for permission verification |
| EIP-191 signature format | "\x19Ethereum Signed Message:\n" + len(message) + message |
| Hardhat default accounts | A fixed mnemonic makes private keys of all test accounts publicly known |
| OP eth_call unreliability | Optimism L2 simulated calls may differ from actual execution results |
| RPC rate limiting strategy | Public nodes have rate limits, requiring request spacing or paid RPC |
| Minter whitelist | The contract uses mapping(address => bool) to control function access |
| Storage slot mapping | Mapping 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)