During the TipRun audit on HackenProof, I found a flaw in the perpetual trading deleverage flow that initially looked like a missing authorization check.
The TYPE_DELEVERAGE payload did not contain a user signature.
That observation alone was not enough to prove a vulnerability. A deleverage mechanism can legitimately be forced if it exists to reduce protocol risk. In that design, requiring the affected user to approve every operation could defeat the purpose of the mechanism.
The real issue was that TipRun did not replace user consent with objective safety rules enforced by the contracts.
The deleverage path accepted concrete economic terms without a signature or nonce, did not require the affected account to be unsafe, did not constrain the collateral amount to the oracle price, and did not prevent the same payload from being processed again.
That combination allowed a healthy account to lose collateral through the authorized batch execution path.
I reported the issue during the audit. HackenProof later validated it as Critical.
The protocol model
TipRun processes protocol operations through a sequencer and batch execution architecture.
Normal user actions rely on signed data. The contracts verify the relevant authorization before applying changes to user accounts.
The deleverage transaction followed a different pattern.
struct Deleverage {
address deleveragableAddress;
uint64 deleveragableAccountId;
address deleveragerSigner;
uint64 deleveragerAccountId;
uint64 syntheticAssetId;
int256 amountSynthetic;
int256 amountCollateral;
int256 deleveragerIsBuyingSynthetic;
}
The struct contains account identifiers, signer addresses, an asset identifier, two amounts, and a direction.
It does not contain a signature, nonce, deadline, or signed hash that binds those terms to either user.
That can still be valid if deleverage is intentionally forced. The problem begins when the contract also fails to define the limits of what may be forced.
Signer registration was treated as sufficient authorization
Inside DeleverageTransLib, the protocol checked whether the supplied addresses were registered signers for the two accounts.
require(
signerManager.checkSigner(deleveragable, self.deleveragableAddress),
"DeleverageTransLib: deleveragable signer unauthorized"
);
require(
signerManager.checkSigner(deleverager, self.deleveragerSigner),
"DeleverageTransLib: deleverager signer unauthorized"
);
The underlying check was simple:
function checkSigner(uint64 accountId, address signer) public view returns (bool) {
uint256 permissions = signerPermissions[accountId][signer];
return permissions != 0;
}
This proves that an address has permissions for an account.
It does not prove that the address approved the current deleverage terms.
That distinction matters because the transaction supplied all economically relevant values directly. The account identifiers, synthetic asset, synthetic amount, collateral amount, and direction were never cryptographically bound to user approved data.
The protocol therefore knew that the addresses were registered, but it did not know whether those addresses had authorized the operation being executed.
Why forced deleverage still needed stronger checks
If TYPE_DELEVERAGE is meant to be voluntary, the missing signatures and nonces are already enough to make the authorization model incomplete.
But I wanted to test the stronger interpretation: assume deleverage is intentionally forced.
Under that model, the contracts should enforce the conditions that make a forced operation legitimate.
The affected account should actually require intervention.
The direction should reduce existing exposure.
The operation should not push a position through zero into the opposite side.
The amount of collateral exchanged should remain within a valid range relative to the oracle price and any configured penalty.
The same instruction should not be executable twice.
The implementation did not enforce those properties.
That changed the issue from a simple missing signature into an unconstrained forced deleverage path.
The state update trusted the supplied amounts
The state transition used the values from the payload directly.
if (self.deleveragerIsBuyingSynthetic == 1) {
deleveragerSyntheticDelta = self.amountSynthetic;
deleveragerCollateralDelta = -self.amountCollateral;
deleveragableSyntheticDelta = -self.amountSynthetic;
deleveragableCollateralDelta = self.amountCollateral;
} else {
deleveragerSyntheticDelta = -self.amountSynthetic;
deleveragerCollateralDelta = self.amountCollateral;
deleveragableSyntheticDelta = self.amountSynthetic;
deleveragableCollateralDelta = -self.amountCollateral;
}
There was no independent calculation that derived the collateral amount from the oracle price.
That created a direct question for the proof of concept:
What happens if the oracle says one synthetic unit is worth one collateral unit, but the deleverage payload requests one thousand collateral units?
Building the proof through real protocol paths
I wanted the proof to demonstrate the issue without direct storage mutation or artificial calls to internal accounting functions.
The test created two accounts with registered signers.
The victim deposited 100000 collateral.
The recipient deposited 1 collateral.
A real signed TYPE_TRADE then opened opposing perpetual positions.
After the trade, the victim held 100001 collateral and a synthetic position of 0 − 1.
The recipient held 0 collateral and a synthetic position of 1.
The victim was healthy before deleverage. Its normalized account value was 100000, while its normalized risk value was only 1.
This established that the test was not exercising a legitimate emergency action against an insolvent account.
Executing arbitrary deleverage terms
The test then submitted a normal TYPE_DELEVERAGE payload through the batch execution path.
The synthetic amount was 1.
The collateral amount was 1000.
The oracle price for one synthetic unit was 1.
The economically fair collateral value was therefore 1, but the payload requested 1000.
The contract accepted it.
After execution, the victim held 99001 collateral and no remaining synthetic position.
The recipient held 1000 collateral and no remaining synthetic position.
The victim had lost 1000 collateral units while the recipient had gained the same amount.
After accounting for the synthetic exposure that was removed, the victim lost 999 units of value and the recipient gained 999 units of excess value.
The important point is not merely that the transaction was unsigned. The contract accepted economically arbitrary terms against a healthy account.
Turning the internal gain into external tokens
A balance change inside a protocol is not always equivalent to realizable loss.
So the next step was to test whether the recipient could actually remove the gained collateral.
After receiving 1000 collateral through deleverage, the recipient submitted a normal signed TYPE_WITHDRAWAL.
The withdrawal succeeded.
The recipient received 1000 ERC20 units externally, and the system balance inside LoadingZone decreased accordingly.
This confirmed that the impact was not limited to internal accounting. The value gained through the malformed deleverage operation could leave the protocol through its legitimate withdrawal path.
Replay and position crossing
TYPE_DELEVERAGE also lacked transaction level replay protection.
The payload contained no nonce or unique identifier that would mark it as already processed.
I executed the exact same payload a second time.
It succeeded again.
The victim collateral fell to 98001, while the recipient collateral increased to 2000.
The second execution also pushed the synthetic positions through zero and into the opposite direction.
The victim synthetic balance became 1.
The recipient synthetic balance became 0 − 1.
This proved that the same unsigned instruction could be reused and that the path did not enforce exposure reducing behavior.
Control tests
The proof also checked neighboring security boundaries so that the result could not be explained by unrelated broken protections.
An unregistered signer still reverted.
A raw attempt to withdraw more collateral than allowed still reverted.
An external account could not directly call the protected transaction processor path.
Those controls passed.
The finding therefore did not depend on an arbitrary externally owned account bypassing the batcher.
The vulnerable behavior happened after a deleverage payload reached the legitimate authorized execution path.
What the test suite proved
The Foundry suite passed with nine tests and no failures.
The proof established all of the following:
Real signed
TYPE_TRADEtransactions created the initial market state.The victim was healthy before deleverage.
The
Deleveragepayload contained no signature and no nonce.The protocol accepted
1000collateral for1synthetic while the oracle price was1.The victim lost collateral and the recipient gained it.
The recipient externalized the gained collateral through a real signed withdrawal.
The same deleverage payload executed twice.
Unregistered signer validation still worked.
Raw over withdrawal protection still worked.
Direct unauthorized access to the transaction processor still failed.
The proof used local Foundry execution and real protocol paths. It did not depend on an RPC fork, governance action, direct vulnerable state mutation, or owner compromise.
Running the proof
The complete suite was executed with:
forge test --match-contract DeleveragePoC -vvv
The result was:
Suite result: ok.
9 passed
0 failed
0 skipped
I also separated the core properties into dedicated tests for cash out, healthy account deleverage, replay, price fairness, guards, and arithmetic.
This made it easier to verify each part of the exploit independently.
Why HackenProof classified it as Critical
I originally reported the issue as High because I wanted to remain conservative around the authorized batch trust boundary.
HackenProof later validated it as Critical.
That classification makes sense when the full impact is considered.
The contracts accepted attacker chosen economic terms against a healthy account, allowed value to be transferred to another account, and allowed that value to be withdrawn from the system.
The same payload could also be replayed.
This was not a harmless mismatch between documentation and implementation. It was a realizable collateral loss path inside a legitimate protocol transaction flow.
The contest economics
The technical severity and the financial outcome were very different.
The submission fee for this report was $5.
The same vulnerability was independently reported by 27 researchers, so the Critical reward was shared across the valid reports.
Across the TipRun contest, my total loss reached $18.
That is one of the realities of competitive audit programs. Finding a Critical vulnerability does not guarantee a large payout when many researchers reach the same issue and the reward is divided among them.
How I would fix it
The correct remediation depends on the intended semantics of TYPE_DELEVERAGE.
If deleverage is voluntary, every economically relevant field should be bound to typed signed data, and the protocol should consume nonces before applying state changes.
The signed message should cover the relevant account identifiers, asset, synthetic amount, collateral amount, direction, and any applicable deadline.
If deleverage is intentionally forced, victim consent is not required, but the contract must enforce the conditions that make the operation safe.
The affected account should be unsafe before forced deleverage is allowed.
The direction should reduce an existing synthetic exposure.
The operation should not cross the position through zero.
The collateral amount should be bounded by the oracle price together with any explicitly configured premium or penalty.
The protocol should also include replay protection so that the same deleverage instruction cannot be processed more than once.
These guarantees should exist on chain.
A trusted sequencer deciding that an operation is reasonable is not equivalent to the contract enforcing the financial invariants itself.
The broader lesson
Privileged execution paths deserve the same economic scrutiny as public entry points.
It is easy to stop at the question of who is allowed to call a function.
But access control does not guarantee that the data supplied by an authorized caller is safe.
A registered signer is not the same thing as a signer who approved the current message.
A forced protocol action is not automatically safe merely because the caller is trusted.
The more important question is:
Once an authorized operation reaches the contract, what prevents economically impossible terms from being accepted?
In this case, the contract did not enforce enough.
TYPE_DELEVERAGE accepted supplied terms after checking signer registration, but without cryptographic consent and without the objective constraints required for safe forced deleverage.
That gap was enough to turn a risk management mechanism into a direct collateral transfer primitive.

Top comments (0)