How TipRun’s Liquidation Path Could Force Healthy Accounts Into Arbitrary Terms
During the TipRun audit on HackenProof, I found a flaw in the perpetual trading liquidation flow that was easy to misread as a simple signature issue. It was not. TYPE_LIQUIDATE contained a signed LimitOrder, but that
During the TipRun audit on HackenProof, I found a flaw in the perpetual trading liquidation flow that was easy to misread as a simple signature issue.
It was not.
TYPE_LIQUIDATE contained a signed LimitOrder, but that signature belonged to the liquidator. The account being liquidated did not sign the forced terms, which can be perfectly valid in a liquidation design.
The real security requirement was therefore different: if the victim does not consent, the contracts must prove that the victim is actually eligible for liquidation and that the forced economic terms stay inside a safe range.
TipRun did not enforce those conditions before resizing the target account.
The result was a path where a healthy account could be forced into unfavorable liquidation terms through the authorized batch execution flow. The proof also showed that most of the resulting gain could be withdrawn as ERC20 collateral.
I originally submitted the finding as High. HackenProof ultimately validated it as Critical. The same issue was independently reported by 31 researchers, so the Critical reward was shared across the valid submissions.
My payout was $4.84. The submission fee was $5, and my total loss across the TipRun contest reached $18.
The security boundary was not the liquidator signature
The liquidation payload was:
struct Liquidate {
LimitOrder liquidatorOrder;
uint64 liquidatedUid;
int256 actualCollateral;
int256 actualSynthetic;
uint256 actualLiquidatorFee;
}
The important detail is that the only signed object is liquidatorOrder.
That signature can authenticate the liquidator side of the transaction. It does not prove that liquidatedUid is below maintenance, that the target account has an exposure that should be reduced, or that the amount of collateral being forced onto the victim is economically fair.
Those properties need separate on chain enforcement.
Without them, a valid liquidator signature is not enough to make the forced state transition safe.
The liquidation payload was forwarded without an eligibility gate
PerpPlugin decoded TYPE_LIQUIDATE and forwarded it to LiquidateTransLib:
if (txType == TYPE_LIQUIDATE) {
Liquidate memory liquidate = abi.decode(payload, (Liquidate));
CollateralAssetInfo memory collateralInfo = generalConfig.getCollateralAssetInfo();
liquidate.process(
position,
funding,
generalConfig,
order,
signer,
collateralInfo.assetId,
generalConfig.getFeeAccountId(),
transactionProcessor
);
emit LiquidateProcessed(liquidate);
return true;
}
There was no check here asking whether the target account was actually liquidatable.
The library then derived the victim deltas directly from the submitted amounts:
int256 liquidatedCollateralDelta = 0;
int256 liquidatedSyntheticDelta = 0;
if (liquidate.liquidatorOrder.isBuyingSynthetic) {
liquidatedCollateralDelta = liquidate.actualCollateral;
liquidatedSyntheticDelta = -liquidate.actualSynthetic;
} else {
liquidatedCollateralDelta = -liquidate.actualCollateral;
liquidatedSyntheticDelta = liquidate.actualSynthetic;
}
Later, those deltas were applied to the target account:
position.updatePosition(
liquidatedPosition,
liquidatedUid,
liquidatedCollateralDelta,
syntheticAssetId,
liquidatedSyntheticDelta
);
The path performed basic validity checks such as rejecting a zero target account, validating the collateral asset, and requiring the synthetic asset to be tradable.
What it did not do before mutation was prove that the account was below maintenance or bound actualCollateral to the oracle price.
That was the core failure.
Why the generic margin check was not enough
At first glance, TipRun already had risk validation around position transitions, so I needed to determine whether that logic indirectly protected the liquidation path.
It did not.
The relevant risk logic returned successfully when the updated position was healthy:
(int256 updatedTotalValue, int256 updatedTotalRisk) =
getAccountPositionStatusWithBoundaryCheck(fullUpdated);
if (updatedTotalRisk <= updatedTotalValue * FXP_32_ONE) {
return;
}
This answers whether the resulting state passes a generic margin condition.
Liquidation eligibility is a different question.
A forced liquidation should first establish that the original account is below the required maintenance threshold. A healthy post state does not prove that the original account was eligible to be forcibly changed.
This distinction matters because the vulnerable path could take a healthy account, apply forced terms, and still end in another healthy state. The generic transition check would accept that result without ever answering whether liquidation should have happened in the first place.
Building the proof through real protocol paths
I built the proof using the real TYPE_TRADE, TYPE_LIQUIDATE, and TYPE_WITHDRAWAL paths.
The victim deposited 1000 collateral and opened a small short through signed orderbook trades.
Before liquidation, the victim state was:
Collateral: 1001
Synthetic: -1
Normalized value: 1000
Normalized risk: 1
The victim was therefore far above maintenance.
This was important because the exploit did not depend on starting from an already unsafe account.
The liquidator then signed its own order, and TYPE_LIQUIDATE was submitted through the authorized batch path with these terms:
Synthetic amount: 1
Submitted collateral: 100
Oracle fair collateral: 1
The oracle fair value for one synthetic unit was 1.
The submitted liquidation used 100.
The contract accepted it.
After liquidation, the victim state became:
Collateral: 901
Synthetic: 0
The liquidator state became:
Collateral: 100
Synthetic: -1
The victim lost 99 units of normalized value relative to the oracle fair exchange.
The liquidator gained the same 99.
The important point is that the loss came from forced terms applied to an account that was not liquidatable.
Most of the gain could be withdrawn
An internal accounting gain is not enough by itself to prove realizable economic impact.
So the proof continued through the normal withdrawal path.
After liquidation, the protocol reported that the liquidator could safely remove 98 collateral.
The liquidator submitted a valid signed TYPE_WITHDRAWAL for exactly that amount.
The withdrawal succeeded.
The external recipient received 98 ERC20 units, and LoadingZone lost the same amount from its system balance.
After the withdrawal, the liquidator still had:
Collateral: 2
Synthetic: -1
Normalized value: 1
Normalized risk: 1
The account remained at maintenance.
This isolated the liquidation issue from a separate withdrawal margin problem. The proof did not require an excessive withdrawal to realize the gain.
Liquidation could even create exposure
The missing eligibility logic had another consequence.
I tested a healthy account with collateral but no synthetic position:
Collateral: 1000
Synthetic: 0
TYPE_LIQUIDATE still executed.
Afterward, the account had:
Collateral: 900
Synthetic: 1
Instead of reducing an existing risky position, the liquidation path created synthetic exposure in an account that previously had none.
That behavior showed why liquidation needs an explicit exposure reduction rule in addition to a maintenance check.
Control cases
The proof included control tests to make sure the result was not caused by unrelated protections failing.
A zero liquidatedUid reverted.
A collateral asset mismatch reverted.
A nontradable synthetic asset reverted.
A mutated liquidator order reverted.
A direct caller outside the authorized batch path reverted.
A withdrawal beyond the safely removable collateral reverted.
The test suite also included a genuinely undercollateralized victim. After a large oracle price move, that account fell below maintenance and a bounded liquidation still succeeded.
This control matters because the correct fix should preserve valid liquidation while rejecting invalid forced liquidation.
Separate from the deleverage finding
I had previously reported a different issue in TYPE_DELEVERAGE, so I explicitly isolated the two findings.
This proof never executed TYPE_DELEVERAGE.
It did not depend on a missing deleverage nonce.
It did not depend on deleverage replay.
The affected transaction was TYPE_LIQUIDATE, the main affected library was LiquidateTransLib, and the signed object was the liquidator LimitOrder.
The root cause here was that liquidator side terms could be applied to a healthy third party account without proving liquidation eligibility or enforcing an oracle price bound.
Test results
The full Foundry suite completed successfully:
12 passed
0 failed
0 skipped
The tests covered healthy account liquidation, price bounds, cash out, zero exposure behavior, valid liquidation, guard conditions, arithmetic, and explicit isolation from the deleverage path.
The proof used local execution through real protocol components. It did not depend on a mainnet fork, RPC access, governance action, direct mutation of the vulnerable state, or external oracle manipulation.
Why the final classification was Critical
I originally submitted the report as High because I wanted to remain conservative around the authorized batch trust boundary.
HackenProof later accepted it as Critical.
The final impact chain was concrete.
A healthy account could be selected for a forced liquidation.
The path accepted terms where one synthetic unit corresponded to 100 collateral even though the oracle fair value was 1.
The victim lost measurable value.
The opposite side gained that value.
Most of the gain could then be externalized through a legitimate signed withdrawal.
The same liquidation mechanism could also create exposure in a healthy account that had none.
That combination turned a protocol risk control into a realizable collateral loss path once invalid terms entered the authorized batch execution flow.
The contest economics
The financial outcome was very different from the technical severity.
The report was validated as Critical, but the issue had been independently reported by 31 researchers.
The final payout to me was $4.84.

The submission itself cost $5.
Across the TipRun contest, my total loss was $18.
So this Critical report did not recover even its own submission fee.
That is one of the realities of competitive audit programs: duplicate density and reward sharing can make the economics almost unrelated to the severity of the vulnerability.
How I would fix it
The liquidation flow should remain forced. Requiring the victim to sign would undermine the purpose of liquidation.
The correct fix is to enforce the conditions that make forced liquidation legitimate before the target account is mutated.
The protocol should require all of the following:
The target account is below maintenance before liquidation.
The target has an existing exposure in the direction being reduced.
The liquidation cannot cross the position through zero and create opposite exposure.
actualCollateralstays within an oracle derived bound that includes only the configured liquidation penalty.
These checks should run before position.updatePosition() applies the victim deltas.
The liquidator signature can continue to authenticate the liquidator order. The missing protection is the on chain validation for the account that does not sign.
The broader lesson
Forced protocol actions require stronger invariants, not weaker ones.
When user consent is intentionally removed from a flow, the contract has to replace that consent with objective rules that define exactly when the action is allowed and how far it can go.
A valid signature from one side does not authorize arbitrary consequences for another account.
The useful security question is therefore not only:
Who signed this operation?
It is also:
What on chain rule proves that forcing these terms onto the affected account is legitimate?
In TipRun’s liquidation path, that second guarantee was missing.
That gap was enough to let an authorized liquidation flow apply economically unfair terms to healthy accounts and turn a risk management mechanism into a collateral loss primitive.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.