Description
The InheritanceManager contract inherits from the Trustee contract, which contains a global variable assetToPay that specifies which token should be used to purchase NFTs. The createEstateNFT() function in InheritanceManager sets this global variable each time a new NFT is created:
function createEstateNFT(string memory _description, uint256 _value, address _asset) external onlyOwner {
uint256 nftID = nft.createEstate(_description);
nftValue[nftID] = _value;
assetToPay = _asset;
}
This design flaw means that the payment token is not associated with individual NFTs, but is a global setting that affects all NFTs in the system. As a result, each time a new NFT is created with a different payment token, it changes the required payment token for all previously created NFTs.
Impact
This vulnerability has several significant impacts:
-
Payment Asset Mismatch: Beneficiaries expecting to buy out an NFT with a specific token (e.g., a stablecoin like USDC) may suddenly be required to use a different, potentially less valuable token.
-
Value Manipulation: An owner could intentionally manipulate the payment asset just before inheritance, changing it from a valuable token to a less valuable one, thereby reducing the effective compensation other beneficiaries would receive.
-
Contract Intent Violation: The contract's intent appears to be that each NFT should have its own associated payment token, but the implementation fails to achieve this.
Proof of Concept
The following test demonstrates this vulnerability:
pragma solidity 0.8.26;
import "forge-std/Test.sol";
import "../src/InheritanceManager.sol";
contract PoCTest is Test {
InheritanceManager public inheritanceManager;
address public owner;
address public tokenA;
address public tokenB;
function setUp() public {
inheritanceManager = new InheritanceManager();
owner = inheritanceManager.getOwner();
tokenA = address(0xA1);
tokenB = address(0xB2);
}
function test_AssetToPayOverwrite() public {
vm.prank(owner);
inheritanceManager.createEstateNFT("Asset 1", 1000, tokenA);
address paymentAssetAfterFirstNFT = inheritanceManager.getAssetToPay();
console.log("After creating NFT #0:");
console.log("- Payment token (assetToPay):", paymentAssetAfterFirstNFT);
assertEq(paymentAssetAfterFirstNFT, tokenA, "assetToPay should be set to tokenA");
vm.prank(owner);
inheritanceManager.createEstateNFT("Asset 2", 500, tokenB);
address paymentAssetAfterSecondNFT = inheritanceManager.getAssetToPay();
console.log("\nAfter creating NFT #1:");
console.log("- Payment token (assetToPay):", paymentAssetAfterSecondNFT);
assertEq(paymentAssetAfterSecondNFT, tokenB, "assetToPay should now be set to tokenB");
assertTrue(paymentAssetAfterSecondNFT != tokenA, "assetToPay should no longer be tokenA");
console.log("\n----- VULNERABILITY DEMONSTRATION -----");
console.log("Initial payment token required for first NFT:", tokenA);
console.log("Current payment token required for ALL NFTs:", tokenB);
}
}
Place the test in the test folder and run it with the following command:
forge test --match-test test_AssetToPayOverwrite -vv
Recommendation
There are two potential solutions to this issue:
Associate payment assets with individual NFTs: Store the payment token for each NFT in a mapping rather than using a global variable:
mapping(uint256 nftID => address paymentToken) public nftPaymentAsset;
function createEstateNFT(string memory _description, uint256 _value, address _asset) external onlyOwner {
uint256 nftID = nft.createEstate(_description);
nftValue[nftID] = _value;
nftPaymentAsset[nftID] = _asset;
}
function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
address paymentToken = nftPaymentAsset[_nftID];
...
}
Make assetToPay immutable after first set: If having a single payment token for all NFTs is intended, consider making it immutable after the first NFT is created:
bool public assetToPayLocked = false;
function createEstateNFT(string memory _description, uint256 _value, address _asset) external onlyOwner {
uint256 nftID = nft.createEstate(_description);
nftValue[nftID] = _value;
if (!assetToPayLocked) {
assetToPay = _asset;
assetToPayLocked = true;
}
}
The first approach is preferred as it provides more flexibility and correctly implements the apparent intention of the contract design.