How Any User Could Trade Inside Anyone Else's Perpetuals Account
Andrei Gliga·September 8, 2026·
Most access-control bugs in trading systems are bugs of omission in an obvious place: a missing owner check on a withdrawal, an admin function without a role gate. This one was more interesting, because the missing check was not in an obvious place.
The result was that any user, holding nothing but a valid session on their own account, could make trades happen inside anyone else's account, at prices they chose, and keep the difference.
The exception that shapes the model
Every account-based trading system starts from one rule: only you may act on your account. Perpetuals are obliged to break it.
A perpetual position is leveraged, so an account's equity is a function of a price the account does not control. When that price moves far enough, an account's collateral no longer covers its position. If nobody closes the position while collateral remains, the shortfall does not stay with the account that created it, becomes bad debt, paid by an insurance fund, a socialized-loss mechanism, or in the worst case by the protocol's other users. Liquidation is the mechanism that stops a single trader's loss from becoming everyone's loss, and its usefulness depends on how quickly it fires.
So the interesting question in any such codebase is not "is authorization enforced?" It is: wherever caller and target are not the same entity, what enforces the conditions that justify the liquidation?
Where the divergence is declared
The protocol we reviewed carries a user-supplied field describing whose account it should act on for every action:
/// AuthType requested by an action.
pub enum AuthType {
/// Normal placement for the caller's own account. The default;
Owner,
/// Liquidation / derisk placement against a target account.
/// Execution enforces liquidation rules.
Liquidation(TargetId),
}The declaration is resolved once, at the front of action validation:
pub fn authorize_type(
users: &Users,
caller_pubkey: UserKey,
caller_account_id: AccountId,
auth_type: AuthType,
) -> Result<AuthResult, Error> {
match auth_type {
// The target is the caller
AuthType::Owner => Ok(AuthResult {
caller_account_id,
target_account_id: caller_account_id,
authority: Authority::Owner,
}),
// No check whatsoever. Any authenticated caller may name any target.
AuthType::Liquidation(target_account_id) => Ok(AuthResult {
caller_account_id,
target_account_id,
authority: Authority::Liquidator,
}),
}
}At first glance, the second arm looks bad, but it is a deliberate deferral, relying on downstream execution to enforce liquidation rules. The auth layer authorizes the shape of the request and hands the operation a result stamped Liquidator; the operation is expected to check that liquidating this target is actually permissible by checking that the account is below its maintenance threshold, and that the action can only reduce its risk.
Original state
Initially, the liquidation checks were made in two different ways:
// 1. Revert- action has nothing to do with liquidation.
ActionKind::A(_) | ActionKind::B(_)
| ActionKind::C(_) => {
ensure!(ctx.auth_type() != AuthType::Liquidation, Error::NotAccountOwner);
// ...
}
// 2. Conditional liquidation path + health checks.
ActionKind::OrderPlace(order) => {
// ...
if ctx.auth_type() == AuthType::Liquidation {
// Account margin checks
// Verify order is debt-reducing on the target
}
// ...
}The second category of actions which do accept liquidation intent are narrowed to debt-reducing sells, and the account's margin state is validated before and after the trade, with a liquidation-specific rule requiring that health strictly improve.
Then the protocol added a component.
Two new paths, zero checks
The new components let a market operate in a P2P trade mode where liquidity is not supplied by a public limit order book. A trader posts a priced order, and an approved counterparty may fill it at the posted price or better, subject to a band anchored on the index price. Two new subactions implement it: NegotiatedPlace (the requester posts) and NegotiatedFill (the counterparty fills).
Neither path asked what authority it carries. Neither path was a liquidation path.
Based on the above liquidation checks, a user may reasonably expect that the NegotiatedPlace and NegotiatedFill actions also result in a revert (1. above) if invoked within a liquidation AuthType. This was not the case, and it permitted the scenario where Alice places/fills orders on behalf of Bob, without the restrictions of a conditional liquidation path (2. above) as follows.
AuthType::Liquidation(bob_account_id) => Ok(AuthResult {
alice_account_id,
bob_account_id, // caller doesn’t have to be the target in a liquidation context
authority: Authority::Liquidator,
}),The target account from the attacker's order is passed as the owner, so the order is created, recorded, and margin-encumbered inside a victim's account. The victim signed nothing.
Why the counterparty whitelist did not help
NegotiatedFill is the interesting one, because it does have an account-scoped check. The component's central security requirement was that only approved counterparties may fill these orders, and the requirement is enforced:
ActionKind::NegotiatedFill(fill) => {
let fill = negotiated::validate_fill_input(fill, C::CONFIG)?;
// ...
// Check the counterparty is in the approved set.
ensure!(
book.approved_counterparties.contains(&target_account_id),
Error::NotAllowed,
"negotiated fill requires the target account to be an approved counterparty"
);
// ...
// Risk is validated.
negotiated::validate_fill_risk::<C, _, _>(&order, &target_account)?;
// Plan is applied
let receipt = negotiated::fill(&order, target_account_id);
}target_account_id is the account checked against the whitelist, and it is a value the attacker supplied, in a field that requires no authorization to populate.
It answers "is the account being used a permitted counterparty?" when the question that needed answering was "does the caller control the account being used?" Under the intended flow (which was not liquidation), those questions have the same answer, because the account being used is the caller, which is exactly why the substitution went unnoticed.
The economics of a forged fill
Why does controlling the counterparty side of a trade make money? Because in the intended flow, the counterparty's willingness to trade at a price is expressed by the act of submitting the fill. The engine therefore never asks whether the price is good for the counterparty. It asks two things:
// The fill must be at least as good as the requester's posted limit...
ensure!(
price_at_least_as_good(side, order.price, fill.price),
Error::ExecutionLimitPrice
);
// ...and must sit inside the oracle-anchored band.
check_price_band(market, fill.price, price_band)?;Both constraints protect the requester and the market. Neither protects the counterparty, because the counterparty was assumed to be the one setting the price of the order execution. Once the attacker controls both sides, the only remaining question is how far apart the two legs of a round trip can be - and that is the width of the price band.
Let P be the index price, b the band fraction, S the size. Executable prices span [(1−b)P, (1+b)P]. The attacker:
- Posts a bid at (1−b)P from their own account and fills it while impersonating the victim. The attacker is long at the bottom of the band; the victim is short at the bottom of the band.
- Posts an ask at (1+b)P from their own account and fills it while impersonating the victim again. The attacker closes long at the top of the band; the victim closes short at the top of the band.
The attacker realizes ((1+b)P − (1−b)P) · S = 2bPS. The victim realizes exactly the negative of it. Both accounts finish flat, which means the sequence is repeatable.
As a fraction of the notional traded, one round trip extracts 2b. In the configuration we tested - a 20% band and a $2,500 index price - that is 40% of notional per round trip: with 100 units of size, $100,000 moved per round trip, in four actions signed by the attacker and zero signed by the victim. Two round trips took the victim's balance from $1,000,000 to $800,000 and the attacker's from $100,000 to $300,000, with both accounts flat (no open orders) at the end of each cycle.
Three ways to use the attack vector
Because the requester side has no whitelist requirement at all and the counterparty side's requirement is satisfied by choosing a whitelisted victim, both halves of a trade are attacker-controllable. That yields three distinct attacks, which we demonstrated as three separate proof-of-concept tests.
1. Direct extraction from an approved counterparty. The attacker is the requester on their own account and impersonates a whitelisted counterparty for both fills. Value flows from the counterparty to the attacker, repeatably, at 2b of notional per cycle. The counterparty takes both sides of a losing round trip without submitting a single action.
2. Forcing a trade between two victims. The attacker impersonates an ordinary user as the requester and a whitelisted counterparty as the filler. Neither victim authenticates. Both receive real positions and real profit and loss; the attacker receives nothing and holds no position at any point.
3. Chaining the two. Stage one uses vector 2 to move value from an ordinary user into a whitelisted counterparty: the victim is made to buy at the top of the band and sell at the bottom. Stage two uses vector 1 to move the same amount from that counterparty into the attacker. The counterparty's balance returns to where it started, having served as a relay. The ordinary user absorbs the entire loss.
What bounds the attack, and what does not
The engine is not indifferent to risk here; the fill path validates the target's margin state. That validation rejects an already-bankrupt account, and requires the post-fill state either to keep the account above its maintenance threshold or, if it was already unhealthy, to strictly improve it. There is also a cap on position notional and a per-order size ceiling.
These are real controls that limit the attacker to walking a victim down toward their maintenance threshold rather than to zero in one step. Neither is a control on whose account is being traded.
Remediation
The fix is one line for each of the two paths, refusing liquidation authority outright, exactly as the existing paths already did:
ensure!(ctx.auth_type() != AuthType::Liquidation, Error::NotAccountOwner);Neither of these operations is a liquidation operation. Both should require that the resolved account be one the authenticated caller controls.
Lessons
This issue demonstrates the importance of verifying any change to a codebase before shipping to mainnet, even when the codebase has been previously audited. Updating your threat model and running regression tests while analyzing every potential entry point for an attacker are what catches bugs like this one before they reach mainnet.
We find issues like this because we look at what a new code path changes, not just whether it passes its own tests. If you're shipping a new execution path in a trading engine or auditing one, follow us on X and LinkedIn for more write-ups like this. And if you want a second set of eyes before you ship, reach out at audits@adevarlabs.com.