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

Incomplete NFT Burn in InheritanceManager.sol

Summary

The buyOutEstateNFT() function in the InheritanceManager contract prematurely returns when the caller is a beneficiary, failing to burn the NFT. This allows the same NFT to be purchased multiple times by different beneficiaries.

Vulnerability Details

In the buyOutEstateNFT() function, there's an early return statement inside the distribution loop that prevents the NFT from being burned when the function is called by a beneficiary:

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; // VULNERABILITY: Early return without burning the NFT
} else {
IERC20(assetToPay).safeTransfer(beneficiaries[i], finalAmount / divisor);
}
}
nft.burnEstate(_nftID);
}

The early return statement inside the loop means the nft.burnEstate(_nftID) line is never executed when the function is called by a beneficiary, leaving the NFT intact. Since most legitimate callers will be beneficiaries (due to the onlyBeneficiaryWithIsInherited modifier), this means the NFT will almost never be burned.

PoC

//SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {InheritanceManager} from "../src/InheritanceManager.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
contract NFTBurnVulnerabilityPoC is Test {
InheritanceManager public im;
ERC20Mock public usdc;
address owner = makeAddr("owner");
address beneficiary1 = makeAddr("beneficiary1");
address beneficiary2 = makeAddr("beneficiary2");
address beneficiary3 = makeAddr("beneficiary3");
uint256 public nftId;
function setUp() public {
// Deploy contracts
vm.startPrank(owner);
im = new InheritanceManager();
usdc = new ERC20Mock();
// Setup beneficiaries
im.addBeneficiery(beneficiary1);
im.addBeneficiery(beneficiary2);
im.addBeneficiery(beneficiary3);
// Create estate NFT
im.createEstateNFT("Vulnerable Estate", 3_000_000, address(usdc));
nftId = 1; // First NFT has ID 1
vm.stopPrank();
// Fund accounts for testing
usdc.mint(beneficiary1, 10_000_000);
usdc.mint(beneficiary2, 10_000_000);
usdc.mint(beneficiary3, 10_000_000);
// Trigger inheritance by advancing time and calling inherit
vm.warp(block.timestamp + 100 days);
vm.prank(beneficiary1);
im.inherit();
}
function test_NFTBurnVulnerability() public {
// Get initial balances
uint256 initialB1Balance = usdc.balanceOf(beneficiary1);
uint256 initialB2Balance = usdc.balanceOf(beneficiary2);
// Step 1: First beneficiary buys out the NFT
vm.startPrank(beneficiary1);
usdc.approve(address(im), 10_000_000);
im.buyOutEstateNFT(nftId);
vm.stopPrank();
// Check if payment was made (proving transaction worked)
uint256 b1BalanceAfterBuyout = usdc.balanceOf(beneficiary1);
assertTrue(b1BalanceAfterBuyout < initialB1Balance, "Beneficiary1 should have paid for NFT");
console.log("First buyout successful:");
console.log("Beneficiary1 initial balance:", initialB1Balance);
console.log("Beneficiary1 balance after buyout:", b1BalanceAfterBuyout);
console.log("Difference:", initialB1Balance - b1BalanceAfterBuyout);
// Step 2: Second beneficiary attempts to buy the same NFT
// This would fail if the NFT was properly burned
vm.startPrank(beneficiary2);
usdc.approve(address(im), 10_000_000);
// This should revert if NFT was burned, but works due to vulnerability
im.buyOutEstateNFT(nftId);
vm.stopPrank();
// Verify second buyout succeeded by checking payment was made
uint256 b2BalanceAfterBuyout = usdc.balanceOf(beneficiary2);
assertTrue(b2BalanceAfterBuyout < initialB2Balance,
"Beneficiary2 should have paid for NFT (proving NFT wasn't burned)");
console.log("Second buyout successful (VULNERABILITY):");
console.log("Beneficiary2 initial balance:", initialB2Balance);
console.log("Beneficiary2 after buyout:", b2BalanceAfterBuyout);
console.log("Difference:", initialB2Balance - b2BalanceAfterBuyout);
// Check if beneficiary1 received funds from second buyout
uint256 b1FinalBalance = usdc.balanceOf(beneficiary1);
assertTrue(b1FinalBalance > b1BalanceAfterBuyout,
"Beneficiary1 should have received distribution from second buyout");
console.log("----- VULNERABILITY CONFIRMED -----");
console.log("Same NFT was bought twice, proving it wasn't burned after first purchase");
console.log("Root cause: Early return in buyOutEstateNFT() when caller is a beneficiary");
}
}

PoC Result:

forge test --match-test test_NFTBurnVulnerability -vvv
[⠰] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTBurnVulnerabilityPoC.t.sol:NFTBurnVulnerabilityPoC
[PASS] test_NFTBurnVulnerability() (gas: 166749)
Logs:
First buyout successful:
Beneficiary1 initial balance: 10000000
Beneficiary1 balance after buyout: 8000000
Difference: 2000000
Second buyout successful (VULNERABILITY):
Beneficiary2 initial balance: 10000000
Beneficiary2 after buyout: 8000000
Difference: 2000000
----- VULNERABILITY CONFIRMED -----
Same NFT was bought twice, proving it wasn't burned after first purchase
Root cause: Early return in buyOutEstateNFT() when caller is a beneficiary
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.44ms (1.74ms CPU time)
Ran 1 test suite in 5.59ms (2.44ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

This vulnerability allows the same NFT to be purchased multiple times by different beneficiaries, potentially extracting far more value from the contract than intended. Each beneficiary can buy the same NFT, paying other beneficiaries each time. This breaks the intended one-time buyout mechanism and could result in:

  1. Unintended multiple payments for the same NFT

  2. A permanent NFT that can never be properly acquired or removed from the system

  3. Potential for malicious beneficiaries to extract excess funds from other beneficiaries

The bug is trivial to exploit by simply having multiple beneficiaries call the buyOutEstateNFT() function on the same NFT ID

Tools Used

Foundry

Manuel code review

Recommendations

Move the NFT burning logic outside of the distribution loop to ensure it's always executed, regardless of which beneficiary calls the function:

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);
// Distribute funds to other beneficiaries
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender != beneficiaries[i]) {
IERC20(assetToPay).safeTransfer(beneficiaries[i], finalAmount / divisor);
}
}
// Always burn the NFT after distribution
nft.burnEstate(_nftID);
}
Updates

Lead Judging Commences

0xtimefliez Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Other

Support

FAQs

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