Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Rounding Division Vulnerability in buyOutEstateNFT Function

Description

The buyOutEstateNFT() function in the InheritanceManager contract contains a critical vulnerability related to rounding errors during division operations. These rounding issues can lead to fund leakage or insufficient payment distribution to beneficiaries.

function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
uint256 divisor = beneficiaries.length;
uint256 multiplier = beneficiaries.length - 1;
uint256 finalAmount = (value / divisor) * multiplier;
IERC20(assetToPay).safeTransferFrom(msg.sender, address(this), finalAmount);
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender == beneficiaries[i]) {
return;
} else {
IERC20(assetToPay).safeTransfer(beneficiaries[i], finalAmount / divisor);
}
}
nft.burnEstate(_nftID);
}

The issue occurs due to two consecutive integer division operations:

  1. First division: value / divisor - This rounds down to the nearest integer

  2. Second division: finalAmount / divisor - Another rounding down occurs

These operations cause several critical problems:

  • Funds get "stuck" in the contract due to rounding errors

  • Beneficiaries receive less than their fair share

  • The total amount transferred to beneficiaries is less than the amount transferred from the buyer

Impact

This vulnerability has significant financial implications:

  1. Trapped Funds: Due to rounding errors, tokens remain stuck in the contract after the transaction completes.

  2. Uneven Distribution: Beneficiaries receive slightly less than they should, with the discrepancy growing as the number of beneficiaries increases.

  3. Contract Integrity: The core functionality of fairly distributing funds among beneficiaries is compromised.

Proof of Concept

The following test case demonstrates the vulnerability:

function test_buyOutEstateNFTRoundingIssue() public {
// Setup
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.warp(1);
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
// Create NFT with value that will cause rounding issues (not evenly divisible by 3)
uint256 nftValue = 1000; // Not divisible evenly by 3
im.createEstateNFT("test property", nftValue, address(usdc));
vm.stopPrank();
// Mint tokens to buyer
usdc.mint(user3, 1000);
// Record initial balances
uint256 initialContractBalance = usdc.balanceOf(address(im));
uint256 initialUser1Balance = usdc.balanceOf(user1);
uint256 initialUser2Balance = usdc.balanceOf(user2);
// Execute buyout
vm.warp(1 + 90 days);
vm.startPrank(user3);
usdc.approve(address(im), 1000);
im.inherit();
im.buyOutEstateNFT(1);
vm.stopPrank();
// Check final balances
uint256 finalContractBalance = usdc.balanceOf(address(im));
uint256 finalUser1Balance = usdc.balanceOf(user1);
uint256 finalUser2Balance = usdc.balanceOf(user2);
// Validate results
uint256 totalPaid = (finalUser1Balance - initialUser1Balance) + (finalUser2Balance - initialUser2Balance);
uint256 stuckTokens = finalContractBalance - initialContractBalance;
console.log("Actual balance increase user1:", finalUser1Balance - initialUser1Balance);
console.log("Actual balance increase user2:", finalUser2Balance - initialUser2Balance);
console.log("Total paid to beneficiaries:", totalPaid);
console.log("Tokens stuck in contract:", stuckTokens);
// Assert that tokens are indeed stuck in the contract
assert(stuckTokens > 0);
}

Output:

│ └─ ← [Return]
├─ [559] ERC20Mock::balanceOf(InheritanceManager: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA]) [staticcall]
│ └─ ← [Return] 222
├─ [559] ERC20Mock::balanceOf(user1: [0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF]) [staticcall]
│ └─ ← [Return] 222
├─ [559] ERC20Mock::balanceOf(user2: [0x537C8f3d3E18dF5517a58B3fB9D9143697996802]) [staticcall]
│ └─ ← [Return] 222
├─ [0] console::log("Actual balance increase user1:", 222) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Actual balance increase user2:", 222) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Total paid to beneficiaries:", 444) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Tokens stuck in contract:", 222) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

In this example with an NFT value of 1000 and 3 beneficiaries:

  • The contract calculates finalAmount = (1000 / 3) * 2 = 666

  • Each non-buyer beneficiary receives 666 / 3 = 222

  • Total paid to beneficiaries: 222 * 2 = 444

  • The contract receives 666 tokens but only distributes 444

  • Result: 222 tokens remain permanently stuck in the contract

Recommendation

Restructure the calculation to minimize or eliminate rounding errors:

function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
uint256 beneficiaryCount = beneficiaries.length;
// Calculate individual share more precisely
uint256 individualShare = value / beneficiaryCount;
// Calculate total amount to pay (excluding buyer's share)
uint256 amountToPay = individualShare * (beneficiaryCount - 1);
// Transfer tokens from buyer to contract
IERC20(assetToPay).safeTransferFrom(msg.sender, address(this), amountToPay);
// Distribute funds directly (no secondary division)
for (uint256 i = 0; i < beneficiaryCount; i++) {
if (msg.sender != beneficiaries[i]) {
IERC20(assetToPay).safeTransfer(beneficiaries[i], individualShare);
}
}
nft.burnEstate(_nftID);
}

This revised implementation:

  1. Calculates the individual share once using a single division

  2. Transfers the exact amount needed based on that calculation

  3. Distributes the exact individual share to each non-buyer beneficiary

  4. Avoids the double division that causes compounding rounding errors

  5. Also fixes the early return issue noted in the previous vulnerability report

Tools Used

  • Foundry Testing Framework

  • Transaction Trace Analysis

  • Manual Code Review

Updates

Lead Judging Commences

0xtimefliez Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

buyOutNFT has wrong denominator

truncation of integers

Support

FAQs

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

Give us feedback!