DEV Community

ohmygod
ohmygod

Posted on

The $500M Oversight: How a Missing Validation Check in Injective Let Anyone Drain Any Account

A deep technical analysis of the critical subaccount validation bug in Injective's exchange module — and the bounty dispute that followed.


In November 2025, a whitehat hacker named f4lc0n discovered what might be the most elegant chain-draining vulnerability of the past year: a single missing validation check in Injective's batch order handler that would have let any user steal funds from any account on the entire chain. No special permissions. No flash loans. No price manipulation. Just a crafted transaction.

The fix shipped within 24 hours. The bounty? Still disputed four months later. Let's break down why this bug was so devastating, why it existed in the first place, and what every Cosmos SDK developer should learn from it.

The Architecture: How Injective Subaccounts Work

Injective is a Cosmos SDK-based L1 optimized for decentralized exchange trading. Every user address owns one or more subaccounts, each identified by a 32-byte hex string where the first 20 bytes encode the owner's Ethereum address.

When you submit a trade, the chain must verify that the subaccount referenced in your order actually belongs to you. This is the fundamental authorization boundary — the digital equivalent of checking your ID before letting you withdraw from someone else's bank account.

The ownership check lives in CheckValidSubaccountIDOrNonce():

func CheckValidSubaccountIDOrNonce(sender sdk.AccAddress, subaccountId string) error {
    subaccountAddress, ok := IsValidSubaccountID(subaccountId)
    if !ok {
        return errors.Wrap(ErrBadSubaccountID, subaccountId)
    }
    if !bytes.Equal(subaccountAddress.Bytes(), sender.Bytes()) {
        return errors.Wrap(ErrBadSubaccountID, subaccountId)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Simple, correct, and called through the validation chain: SpotOrder.ValidateBasic(sender)OrderInfo.ValidateBasic(sender, ...)CheckValidSubaccountIDOrNonce(sender, subaccountId).

So far, so good. The problem isn't in this function — it's in who doesn't call it.

The Bug: MsgBatchUpdateOrders Skips Market Order Validation

MsgBatchUpdateOrders is Injective's power-user transaction. It lets you cancel and create multiple orders — limit orders, market orders, binary options — all in a single atomic transaction. Its ValidateBasic method is supposed to validate every sub-order before accepting the transaction.

Here's what the validation does correctly:

// ✅ Cancel orders — ownership checked
for idx := range msg.SpotOrdersToCancel {
    if err := msg.SpotOrdersToCancel[idx].ValidateBasic(sender); err != nil {
        return err
    }
}

// ✅ Limit orders — ownership checked
for idx := range msg.SpotOrdersToCreate {
    if err := msg.SpotOrdersToCreate[idx].ValidateBasic(sender); err != nil {
        return err
    }
}
Enter fullscreen mode Exit fullscreen mode

And here's where it falls apart:

// ❌ Market orders — NO validation at all

// Jumps straight to duplicate checking (no ownership verification)
if err := ensureNoDuplicateMarketOrders(
    sender, msg.DerivativeMarketOrdersToCreate,
); err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

Three entire arrays of market orders are never validated:

  • SpotMarketOrdersToCreate
  • DerivativeMarketOrdersToCreate
  • BinaryOptionsMarketOrdersToCreate

The irony? Standalone market order messages (MsgCreateSpotMarketOrder) do validate correctly. The bug only exists in the batch handler — the exact path a sophisticated attacker would use.

The Exploit: Four Steps to Drain Any Account

What makes this vulnerability truly devastating is how cleanly it chains with Injective's permissionless features:

Step 1: Create a Worthless Token

Using Injective's tokenfactory module, the attacker mints an arbitrary supply of a new token (let's call it SCAM). No governance approval required — token creation is permissionless.

Step 2: Create a Spot Market

The attacker creates a SCAM/USDT spot market. Also permissionless on Injective. Now there's a trading pair where the attacker controls one side completely.

Step 3: Place a Sell Order

The attacker places a limit sell order from their own subaccount, offering to sell SCAM tokens at whatever price they choose — say, 1 SCAM = 10,000 USDT.

Step 4: Force the Victim to Buy

Here's the kill shot. The attacker submits a MsgBatchUpdateOrders transaction with a market buy order in SpotMarketOrdersToCreate, but sets the SubaccountId to the victim's subaccount. Because ValidateBasic never checks market order ownership, the transaction passes all checks.

The victim's USDT buys the attacker's worthless SCAM tokens at the attacker's price. The attacker receives the victim's USDT. The victim gets worthless tokens. The attacker bridges the stolen USDT to Ethereum via Peggo.

Total attack cost: gas fees for ~4 transactions.

The only information the attacker needs is the victim's subaccount ID — which is publicly visible on-chain.

Why This Bug Pattern Is So Common

This isn't a novel vulnerability class. It's a consistency bug — the same check that exists in five places was missed in the sixth:

1. Batch handlers are authorization blind spots. When you have both singular and batch paths, the batch handler must replicate every check from the singular path. Developers often assume the batch handler just "wraps" the individual operations, but in practice, batch ValidateBasic methods frequently implement their own validation logic.

2. Market orders were likely added later. Looking at the code structure, limit order validation was implemented first, with market orders added to the batch handler afterward. When the market order fields were added to the struct, the corresponding validation wasn't added to ValidateBasic. Classic feature accretion bug.

3. The ensureNoDuplicateMarketOrders function is a red herring. It accepts a sender parameter, which might have given developers false confidence that authorization was being checked. But it only checks for duplicate subaccount+market combinations — not whether the sender owns the subaccounts.

What This Means for Cosmos SDK Developers

Audit Your Batch Handlers

If your module has both single-operation and batch-operation message types, generate a validation matrix. If any operation is validated in the single path but not the batch path, you have a bug.

Use Shared Validation Functions

Instead of duplicating validation logic, make each order type validate itself through a single code path:

func (msg MsgBatchUpdateOrders) ValidateBasic() error {
    sender, _ := sdk.AccAddressFromBech32(msg.Sender)

    // Validate ALL order types through the same path
    for _, order := range msg.AllOrders() {
        if err := order.ValidateBasic(sender); err != nil {
            return err
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Write Authorization-Specific Tests

Don't just test that valid orders succeed — test that orders with wrong subaccount IDs fail:

func TestBatchUpdateOrders_RejectsWrongSubaccount(t *testing.T) {
    attackerAddr := sdk.AccAddress(...)
    victimSubaccount := "0xVICTIM_ADDRESS..." 

    msg := MsgBatchUpdateOrders{
        Sender: attackerAddr.String(),
        SpotMarketOrdersToCreate: []*SpotOrder{{
            OrderInfo: OrderInfo{SubaccountId: victimSubaccount},
        }},
    }

    err := msg.ValidateBasic()
    require.Error(t, err) // This would have caught the bug
}
Enter fullscreen mode Exit fullscreen mode

Consider Invariant Testing

Property-based testing tools like rapid (Go's equivalent of Hypothesis) can systematically generate batch messages with mismatched sender/subaccount combinations. A single invariant — "no order in a batch message references a subaccount not owned by the sender" — would catch this entire class of bugs.

The Bounty Dispute: A Cautionary Tale

The timeline tells its own story:

  • Nov 30, 2025: f4lc0n submits vulnerability + PoC via Immunefi
  • Dec 1, 2025: Injective pushes mainnet upgrade vote — before acknowledging the report
  • Dec 1, 2025: Auto-acknowledgment from Injective
  • Dec 14, 2025: Immunefi SLA enforcement triggers due to non-response
  • Feb 11, 2026: Injective confirms validity (73 days later)
  • Mar 5, 2026: Injective offers $50,000 — 10% of their $500,000 maximum
  • Mar 15, 2026: f4lc0n goes public with "injective-wall-of-shame" repo

Injective's Immunefi page explicitly lists a maximum bounty of $500,000 for critical blockchain vulnerabilities. A bug that lets anyone drain any account is about as critical as it gets.

Whether the final payout is justified or not, the handling sends a chilling message to security researchers: you might save half a billion dollars and get ghosted for three months. This is how protocols end up with fewer whitehats and more blackhats.

Lessons for the Ecosystem

  1. Consistency bugs are the new reentrancy. As smart contract security matures, the low-hanging fruit shifts to authorization inconsistencies across code paths.

  2. Permissionless features multiply attack surface. Injective's permissionless token creation and market listing transform an authorization bypass into a complete fund-theft exploit. When designing permissionless systems, assume every authorization check will eventually be bypassed.

  3. Bug bounty programs need teeth. If your program promises $500K for critical bugs, pay $500K for critical bugs. The security researcher community is small, talks to each other, and has long memories.

  4. Batch operations deserve their own audit focus. Add "batch handler authorization parity" as a specific line item in your audit checklist.


The vulnerability has been patched. This article is published for educational purposes based on publicly available information from the researcher's disclosure.

Sources: f4lc0n's disclosure, Protos, Immunefi

Top comments (0)