Asset Duplication Attack

Exploit where players can create multiple copies of valuable in-game NFTs or tokens through contract vulnerabilities, state desynchronization, or race conditions.

Asset Duplication Attacks represent one of the most devastating vulnerabilities in GameFi protocols, allowing attackers to create multiple copies of valuable NFTs, tokens, or in-game items. These exploits can instantly collapse gaming economies, destroy player trust, and result in massive financial losses for both players and protocols.

Types of Asset Duplication Attacks

1. Reentrancy-Based Duplication

Classic reentrancy vulnerabilities applied to gaming assets:

1// VULNERABLE: Reentrancy in item transfer
2contract VulnerableGameItems is ERC721 {
3 mapping(uint256 => bool) public itemInUse;
4 mapping(address => uint256[]) public playerInventory;
5
6 function useItem(uint256 tokenId) external {
7 require(ownerOf(tokenId) == msg.sender, "Not owner");
8 require(!itemInUse[tokenId], "Already in use");
9
10 itemInUse[tokenId] = true;
11
12 // VULNERABILITY: External call before state update
13 IGameEffect(getItemEffect(tokenId)).applyEffect(msg.sender, tokenId);
14
15 // State update happens after external call - too late!
16 updatePlayerStats(msg.sender, tokenId);
17 }
18
19 function transferFrom(address from, address to, uint256 tokenId) public override {
20 require(!itemInUse[tokenId], "Item in use"); // Check
21
22 super.transferFrom(from, to, tokenId); // Transfer
23
24 // VULNERABILITY: No protection against reentrancy during transfer
25 updateInventory(from, to, tokenId);
26 }
27}
28
29// ATTACK CONTRACT: Exploits reentrancy to duplicate items
30contract DuplicationAttack {
31 VulnerableGameItems public gameItems;
32 uint256 public targetTokenId;
33 address public accomplice;
34
35 function exploit(uint256 tokenId, address _accomplice) external {
36 targetTokenId = tokenId;
37 accomplice = _accomplice;
38
39 // Start the attack
40 gameItems.useItem(tokenId);
41 }
42
43 function applyEffect(address player, uint256 tokenId) external {
44 // This function is called during useItem()
45 // We can trigger a transfer during the external call
46 if (msg.sender == address(gameItems)) {
47 // Transfer the item to accomplice while it's still marked as "in use"
48 gameItems.transferFrom(player, accomplice, tokenId);
49
50 // Now we have:
51 // 1. Item transferred to accomplice
52 // 2. Original player still has "in use" state
53 // 3. Both can potentially claim benefits
54 }
55 }
56}
57
58// SECURE: Reentrancy protection
59contract SecureGameItems is ERC721, ReentrancyGuard {
60 mapping(uint256 => bool) public itemInUse;
61
62 function useItem(uint256 tokenId) external nonReentrant {
63 require(ownerOf(tokenId) == msg.sender, "Not owner");
64 require(!itemInUse[tokenId], "Already in use");
65
66 itemInUse[tokenId] = true;
67
68 // Safe external call after state update
69 IGameEffect(getItemEffect(tokenId)).applyEffect(msg.sender, tokenId);
70
71 // Additional validations after external call
72 require(ownerOf(tokenId) == msg.sender, "Ownership changed during use");
73 }
74}

2. State Desynchronization Duplication

When multiple contracts tracking the same asset become out of sync:

1// VULNERABLE: Separate contracts without proper synchronization
2contract GameNFT is ERC721 {
3 // Only handles NFT ownership
4 function transferFrom(address from, address to, uint256 tokenId) public override {
5 super.transferFrom(from, to, tokenId);
6 // Missing: notification to game logic
7 }
8}
9
10contract GameLogic {
11 mapping(address => uint256[]) public playerInventory;
12 mapping(uint256 => ItemState) public itemStates;
13
14 function useItemInBattle(uint256 tokenId) external {
15 require(gameNFT.ownerOf(tokenId) == msg.sender, "Not owner");
16
17 // VULNERABILITY: No check for existing usage
18 itemStates[tokenId] = ItemState.IN_BATTLE;
19
20 // Player can transfer NFT to another address and use it again
21 }
22}
23
24// ATTACK SCENARIO:
25// 1. Player A owns NFT #123
26// 2. Player A calls useItemInBattle(123) - item marked as IN_BATTLE
27// 3. Player A transfers NFT #123 to Player B
28// 4. Player B can call useItemInBattle(123) again
29// 5. Same item is now used in two battles simultaneously
30
31// SECURE: Proper synchronization
32contract SecureGameLogic {
33 mapping(uint256 => ItemState) public itemStates;
34
35 modifier requiresItemOwnership(uint256 tokenId) {
36 require(gameNFT.ownerOf(tokenId) == msg.sender, "Not owner");
37 require(!isItemLocked(tokenId), "Item locked");
38 _;
39 }
40
41 function useItemInBattle(uint256 tokenId) external requiresItemOwnership(tokenId) {
42 // Lock the item to prevent transfers during use
43 lockItem(tokenId, BATTLE_DURATION);
44 itemStates[tokenId] = ItemState.IN_BATTLE;
45
46 emit ItemUsedInBattle(msg.sender, tokenId);
47 }
48
49 function lockItem(uint256 tokenId, uint256 duration) internal {
50 itemLocks[tokenId] = ItemLock({
51 locked: true,
52 owner: msg.sender,
53 expiresAt: block.timestamp + duration
54 });
55
56 // Notify NFT contract about the lock
57 gameNFT.setItemLocked(tokenId, true);
58 }
59}

3. Race Condition Duplication

Exploiting timing windows in multi-step operations:

1// VULNERABLE: Race condition in crafting
2contract VulnerableCrafting {
3 mapping(address => uint256[]) public playerItems;
4 mapping(uint256 => bool) public itemUsed;
5
6 function craftItem(uint256[] calldata inputItems, uint256 outputItemId) external {
7 // Validate inputs
8 for (uint256 i = 0; i < inputItems.length; i++) {
9 require(gameNFT.ownerOf(inputItems[i]) == msg.sender, "Not owner");
10 require(!itemUsed[inputItems[i]], "Item already used");
11 }
12
13 // VULNERABILITY: Gap between validation and consumption
14
15 // Consume inputs
16 for (uint256 i = 0; i < inputItems.length; i++) {
17 itemUsed[inputItems[i]] = true;
18 gameNFT.burn(inputItems[i]);
19 }
20
21 // Mint output
22 gameNFT.mint(msg.sender, outputItemId);
23 }
24}
25
26// ATTACK: Submit multiple identical transactions in same block
27// All pass validation before any are processed, allowing multiple crafts
28
29// SECURE: Atomic operations with locks
30contract SecureCrafting {
31 mapping(bytes32 => bool) public operationExecuted;
32 mapping(address => uint256) public playerNonces;
33
34 function craftItem(
35 uint256[] calldata inputItems,
36 uint256 outputItemId,
37 uint256 nonce
38 ) external {
39 require(nonce == playerNonces[msg.sender]++, "Invalid nonce");
40
41 bytes32 operationId = keccak256(abi.encode(
42 msg.sender,
43 inputItems,
44 outputItemId,
45 nonce
46 ));
47
48 require(!operationExecuted[operationId], "Operation already executed");
49 operationExecuted[operationId] = true;
50
51 // Atomic validation and consumption
52 for (uint256 i = 0; i < inputItems.length; i++) {
53 require(gameNFT.ownerOf(inputItems[i]) == msg.sender, "Not owner");
54 gameNFT.burn(inputItems[i]); // Immediate consumption
55 }
56
57 gameNFT.mint(msg.sender, outputItemId);
58
59 emit ItemCrafted(msg.sender, inputItems, outputItemId, operationId);
60 }
61}

4. Bridge/Cross-Chain Duplication

Assets duplicated across different chains or layers:

1// VULNERABLE: Cross-chain bridge without proper locking
2contract VulnerableBridge {
3 mapping(uint256 => bool) public bridgedTokens;
4
5 function bridgeToL2(uint256 tokenId) external {
6 require(gameNFT.ownerOf(tokenId) == msg.sender, "Not owner");
7 require(!bridgedTokens[tokenId], "Already bridged");
8
9 bridgedTokens[tokenId] = true;
10
11 // VULNERABILITY: Token not locked or burned on L1
12 // Player still has the original token on L1
13
14 // Emit bridge event
15 emit BridgeInitiated(msg.sender, tokenId);
16 }
17}
18
19// SECURE: Proper locking during bridge operations
20contract SecureBridge {
21 enum BridgeState { NONE, PENDING, COMPLETED, FAILED }
22
23 struct BridgeOperation {
24 address owner;
25 uint256 tokenId;
26 uint256 targetChain;
27 BridgeState state;
28 uint256 timestamp;
29 }
30
31 mapping(uint256 => BridgeOperation) public bridgeOperations;
32 mapping(uint256 => bool) public tokenLocked;
33
34 function initiiateBridge(uint256 tokenId, uint256 targetChain) external {
35 require(gameNFT.ownerOf(tokenId) == msg.sender, "Not owner");
36 require(!tokenLocked[tokenId], "Token locked");
37
38 // Lock token immediately
39 tokenLocked[tokenId] = true;
40 gameNFT.setTransferability(tokenId, false);
41
42 bridgeOperations[tokenId] = BridgeOperation({
43 owner: msg.sender,
44 tokenId: tokenId,
45 targetChain: targetChain,
46 state: BridgeState.PENDING,
47 timestamp: block.timestamp
48 });
49
50 emit BridgeInitiated(msg.sender, tokenId, targetChain);
51 }
52
53 function completeBridge(uint256 tokenId) external onlyBridgeValidator {
54 BridgeOperation storage op = bridgeOperations[tokenId];
55 require(op.state == BridgeState.PENDING, "Invalid state");
56
57 // Burn token on source chain
58 gameNFT.burn(tokenId);
59 tokenLocked[tokenId] = false;
60 op.state = BridgeState.COMPLETED;
61
62 emit BridgeCompleted(tokenId);
63 }
64
65 function failBridge(uint256 tokenId) external onlyBridgeValidator {
66 BridgeOperation storage op = bridgeOperations[tokenId];
67 require(op.state == BridgeState.PENDING, "Invalid state");
68
69 // Unlock token on failure
70 tokenLocked[tokenId] = false;
71 gameNFT.setTransferability(tokenId, true);
72 op.state = BridgeState.FAILED;
73
74 emit BridgeFailed(tokenId);
75 }
76}

5. Upgrade-Based Duplication

Assets duplicated during contract upgrades:

1// VULNERABLE: Upgrade without proper asset migration
2contract GameV1 {
3 mapping(address => uint256[]) public playerAssets;
4
5 function upgrade() external onlyOwner {
6 // VULNERABILITY: Assets not properly migrated
7 // Old contract still holds asset state
8 // New contract can mint same assets again
9
10 implementation = gameV2Address;
11 emit Upgraded(gameV2Address);
12 }
13}
14
15// SECURE: Atomic migration during upgrade
16contract GameV2 {
17 mapping(address => uint256[]) public playerAssets;
18 mapping(uint256 => bool) public migrated;
19 bool public migrationCompleted;
20
21 function migrateAsset(uint256 tokenId) external {
22 require(!migrationCompleted, "Migration period ended");
23 require(!migrated[tokenId], "Already migrated");
24
25 address owner = gameV1.ownerOf(tokenId);
26 require(owner != address(0), "Asset doesn't exist");
27
28 // Mark as migrated to prevent double migration
29 migrated[tokenId] = true;
30
31 // Mint in new contract
32 _mint(owner, tokenId);
33
34 // Burn in old contract
35 gameV1.burnFromMigration(tokenId);
36
37 emit AssetMigrated(tokenId, owner);
38 }
39
40 function completeMigration() external onlyOwner {
41 require(block.timestamp > migrationDeadline, "Migration period active");
42 migrationCompleted = true;
43
44 // Any remaining assets in old contract are now inaccessible
45 emit MigrationCompleted();
46 }
47}

Detection and Prevention Mechanisms

1. Asset Integrity Verification

1contract AssetIntegrityChecker {
2 struct AssetRecord {
3 uint256 tokenId;
4 address currentOwner;
5 uint256 lastTransfer;
6 bytes32 stateHash;
7 bool verified;
8 }
9
10 mapping(uint256 => AssetRecord) public assetRecords;
11 mapping(address => uint256) public playerAssetCounts;
12
13 function verifyAssetIntegrity(uint256 tokenId) external view returns (bool) {
14 AssetRecord memory record = assetRecords[tokenId];
15
16 // Verify NFT exists and ownership matches
17 try gameNFT.ownerOf(tokenId) returns (address actualOwner) {
18 if (actualOwner != record.currentOwner) {
19 return false; // Ownership mismatch
20 }
21 } catch {
22 return false; // NFT doesn't exist
23 }
24
25 // Verify state hash
26 bytes32 currentStateHash = calculateStateHash(tokenId);
27 if (currentStateHash != record.stateHash) {
28 return false; // State corruption
29 }
30
31 // Verify player asset count
32 uint256 actualCount = gameNFT.balanceOf(record.currentOwner);
33 if (actualCount != playerAssetCounts[record.currentOwner]) {
34 return false; // Asset count mismatch
35 }
36
37 return true;
38 }
39
40 function auditAllAssets() external returns (uint256 corruptedCount) {
41 uint256 totalSupply = gameNFT.totalSupply();
42
43 for (uint256 i = 1; i <= totalSupply; i++) {
44 if (!verifyAssetIntegrity(i)) {
45 corruptedCount++;
46 emit AssetCorruption(i, "Integrity check failed");
47 }
48 }
49
50 return corruptedCount;
51 }
52}

2. Real-Time Duplication Detection

1contract DuplicationDetector {
2 struct ActivityLog {
3 address player;
4 uint256 tokenId;
5 string action;
6 uint256 timestamp;
7 bytes32 transactionHash;
8 }
9
10 ActivityLog[] public activities;
11 mapping(uint256 => uint256[]) public tokenActivities;
12
13 function logActivity(
14 address player,
15 uint256 tokenId,
16 string memory action
17 ) external {
18 uint256 activityId = activities.length;
19
20 activities.push(ActivityLog({
21 player: player,
22 tokenId: tokenId,
23 action: action,
24 timestamp: block.timestamp,
25 transactionHash: keccak256(abi.encode(block.number, tx.origin, msg.data))
26 }));
27
28 tokenActivities[tokenId].push(activityId);
29
30 // Check for suspicious patterns
31 if (detectSuspiciousActivity(tokenId)) {
32 flagSuspiciousToken(tokenId);
33 }
34 }
35
36 function detectSuspiciousActivity(uint256 tokenId) internal view returns (bool) {
37 uint256[] memory activities = tokenActivities[tokenId];
38
39 if (activities.length < 2) return false;
40
41 // Check for rapid consecutive actions
42 ActivityLog memory lastActivity = activities[activities.length - 1];
43 ActivityLog memory secondLast = activities[activities.length - 2];
44
45 if (lastActivity.timestamp - secondLast.timestamp < 60) { // 1 minute
46 if (keccak256(bytes(lastActivity.action)) == keccak256(bytes(secondLast.action))) {
47 return true; // Same action twice within a minute
48 }
49 }
50
51 // Check for impossible simultaneous usage
52 for (uint256 i = activities.length; i > 0; i--) {
53 ActivityLog memory activity = activities[i - 1];
54
55 if (block.timestamp - activity.timestamp > 3600) break; // 1 hour lookback
56
57 if (keccak256(bytes(activity.action)) == keccak256(bytes("USE_IN_BATTLE")) ||
58 keccak256(bytes(activity.action)) == keccak256(bytes("USE_IN_CRAFTING"))) {
59
60 // Token can't be used in multiple places simultaneously
61 for (uint256 j = i + 1; j <= activities.length; j++) {
62 ActivityLog memory otherActivity = activities[j - 1];
63
64 if (activity.timestamp == otherActivity.timestamp &&
65 !compareStrings(activity.action, otherActivity.action)) {
66 return true; // Simultaneous different uses
67 }
68 }
69 }
70 }
71
72 return false;
73 }
74}

3. Economic Duplication Detection

1contract EconomicAnomalyDetector {
2 struct EconomicSnapshot {
3 uint256 totalTokenSupply;
4 uint256 totalNFTSupply;
5 uint256 playerCount;
6 uint256 timestamp;
7 }
8
9 EconomicSnapshot[] public snapshots;
10 uint256 public constant SNAPSHOT_INTERVAL = 1 hours;
11
12 function takeEconomicSnapshot() external {
13 require(
14 snapshots.length == 0 ||
15 block.timestamp > snapshots[snapshots.length - 1].timestamp + SNAPSHOT_INTERVAL,
16 "Too soon for snapshot"
17 );
18
19 snapshots.push(EconomicSnapshot({
20 totalTokenSupply: gameToken.totalSupply(),
21 totalNFTSupply: gameNFT.totalSupply(),
22 playerCount: getActivePlayerCount(),
23 timestamp: block.timestamp
24 }));
25
26 // Analyze for anomalies
27 if (snapshots.length > 1) {
28 analyzeEconomicTrends();
29 }
30 }
31
32 function analyzeEconomicTrends() internal {
33 if (snapshots.length < 2) return;
34
35 EconomicSnapshot memory current = snapshots[snapshots.length - 1];
36 EconomicSnapshot memory previous = snapshots[snapshots.length - 2];
37
38 // Check for impossible growth rates
39 uint256 tokenGrowthRate = (current.totalTokenSupply - previous.totalTokenSupply) * 100 / previous.totalTokenSupply;
40 uint256 nftGrowthRate = (current.totalNFTSupply - previous.totalNFTSupply) * 100 / previous.totalNFTSupply;
41
42 // Flag suspicious growth
43 if (tokenGrowthRate > 50) { // 50% growth in one hour
44 emit EconomicAnomaly("TOKEN_GROWTH_ANOMALY", tokenGrowthRate);
45 pauseTokenMinting();
46 }
47
48 if (nftGrowthRate > 20) { // 20% NFT growth in one hour
49 emit EconomicAnomaly("NFT_GROWTH_ANOMALY", nftGrowthRate);
50 pauseNFTMinting();
51 }
52
53 // Check supply/demand ratios
54 if (current.totalNFTSupply > current.playerCount * 10) {
55 emit EconomicAnomaly("OVERSUPPLY_DETECTED", current.totalNFTSupply / current.playerCount);
56 }
57 }
58}

Recovery from Asset Duplication

1. Asset Snapshot and Rollback

1contract AssetRecovery {
2 struct AssetSnapshot {
3 uint256 tokenId;
4 address owner;
5 uint256 timestamp;
6 bytes32 stateHash;
7 }
8
9 mapping(uint256 => AssetSnapshot[]) public assetHistory;
10 uint256 public lastValidSnapshot;
11
12 function createAssetSnapshot() external onlyOwner {
13 uint256 snapshotId = lastValidSnapshot++;
14 uint256 totalSupply = gameNFT.totalSupply();
15
16 for (uint256 tokenId = 1; tokenId <= totalSupply; tokenId++) {
17 try gameNFT.ownerOf(tokenId) returns (address owner) {
18 assetHistory[tokenId].push(AssetSnapshot({
19 tokenId: tokenId,
20 owner: owner,
21 timestamp: block.timestamp,
22 stateHash: calculateAssetState(tokenId)
23 }));
24 } catch {
25 // Token doesn't exist, skip
26 continue;
27 }
28 }
29
30 emit SnapshotCreated(snapshotId, totalSupply);
31 }
32
33 function rollbackAssets(uint256 snapshotId) external onlyOwner {
34 require(snapshotId < lastValidSnapshot, "Invalid snapshot");
35
36 uint256 totalSupply = gameNFT.totalSupply();
37
38 for (uint256 tokenId = 1; tokenId <= totalSupply; tokenId++) {
39 AssetSnapshot[] memory history = assetHistory[tokenId];
40
41 if (history.length > snapshotId) {
42 AssetSnapshot memory snapshot = history[snapshotId];
43 address currentOwner = gameNFT.ownerOf(tokenId);
44
45 if (currentOwner != snapshot.owner) {
46 // Restore ownership
47 gameNFT.adminTransfer(currentOwner, snapshot.owner, tokenId);
48 emit AssetRestored(tokenId, currentOwner, snapshot.owner);
49 }
50 }
51 }
52
53 emit AssetsRolledBack(snapshotId);
54 }
55}

2. Compensation Mechanisms

1contract CompensationPool {
2 struct CompensationClaim {
3 address player;
4 uint256 tokenId;
5 uint256 lossAmount;
6 bool verified;
7 bool paid;
8 uint256 timestamp;
9 }
10
11 mapping(uint256 => CompensationClaim) public claims;
12 uint256 public claimCounter;
13 uint256 public compensationPool;
14
15 function fileCompensationClaim(
16 uint256 tokenId,
17 uint256 lossAmount,
18 bytes calldata proof
19 ) external {
20 require(verifyLossProof(msg.sender, tokenId, lossAmount, proof), "Invalid proof");
21
22 uint256 claimId = ++claimCounter;
23 claims[claimId] = CompensationClaim({
24 player: msg.sender,
25 tokenId: tokenId,
26 lossAmount: lossAmount,
27 verified: false,
28 paid: false,
29 timestamp: block.timestamp
30 });
31
32 emit CompensationClaimed(claimId, msg.sender, tokenId, lossAmount);
33 }
34
35 function verifyAndPayCompensation(uint256 claimId) external onlyOwner {
36 CompensationClaim storage claim = claims[claimId];
37 require(!claim.verified, "Already verified");
38 require(compensationPool >= claim.lossAmount, "Insufficient pool");
39
40 claim.verified = true;
41 claim.paid = true;
42 compensationPool -= claim.lossAmount;
43
44 // Pay compensation
45 payable(claim.player).transfer(claim.lossAmount);
46
47 emit CompensationPaid(claimId, claim.player, claim.lossAmount);
48 }
49
50 function fundCompensationPool() external payable onlyOwner {
51 compensationPool += msg.value;
52 emit PoolFunded(msg.value, compensationPool);
53 }
54}

Testing Asset Duplication Vulnerabilities

Comprehensive Duplication Test Suite

1describe("Asset Duplication Security", function() {
2 it("should prevent reentrancy-based duplication", async function() {
3 // Deploy malicious contract
4 const attacker = await MaliciousReentrancy.deploy(gameItems.address);
5
6 // Mint NFT to attacker
7 await gameItems.mint(attacker.address, 1);
8
9 // Attempt reentrancy attack
10 await expect(
11 attacker.attemptDuplication(1)
12 ).to.be.revertedWith("ReentrancyGuard: reentrant call");
13
14 // Verify only one copy exists
15 expect(await gameItems.totalSupply()).to.equal(1);
16 });
17
18 it("should prevent race condition duplication", async function() {
19 // Setup crafting scenario
20 await gameItems.mint(player.address, 1);
21 await gameItems.mint(player.address, 2);
22
23 // Attempt to submit identical crafting transactions
24 const tx1 = gameCrafting.connect(player).craftItem([1, 2], 100, 0);
25 const tx2 = gameCrafting.connect(player).craftItem([1, 2], 100, 0);
26
27 // First should succeed, second should fail
28 await expect(tx1).to.not.be.reverted;
29 await expect(tx2).to.be.revertedWith("Operation already executed");
30
31 // Verify only one output was created
32 expect(await gameItems.ownerOf(100)).to.equal(player.address);
33 expect(await gameItems.totalSupply()).to.equal(1); // Only output exists
34 });
35
36 it("should detect asset state desynchronization", async function() {
37 // Create state desync condition
38 await gameItems.mint(player.address, 1);
39 await gameLogic.useItem(1);
40
41 // Transfer NFT without updating game logic
42 await gameItems.adminTransfer(player.address, player2.address, 1);
43
44 // Verification should detect the desync
45 expect(await assetIntegrity.verifyAssetIntegrity(1)).to.be.false;
46
47 // Audit should flag the corruption
48 const corruptedCount = await assetIntegrity.auditAllAssets();
49 expect(corruptedCount).to.be.greaterThan(0);
50 });
51});

Asset Duplication Attacks represent existential threats to GameFi protocols. These vulnerabilities can instantly destroy gaming economies by inflating asset supplies, breaking game balance, and undermining player trust. Preventing asset duplication requires comprehensive security measures including reentrancy protection, atomic operations, state synchronization, real-time monitoring, and robust testing. Protocols that successfully prevent these attacks build sustainable gaming ecosystems where digital assets maintain their intended scarcity and value.

Need expert guidance on Asset Duplication Attack?

Our team at Zealynx has deep expertise in blockchain security and DeFi protocols. Whether you need an audit or consultation, we're here to help.

Get a Quote

oog
zealynx

Subscribe to Our Newsletter

Stay updated with our latest security insights and blog posts

© 2024 Zealynx