· 6 min read

All Roads Lead to Panic: A Starknet Oracle Story

Pragma is one of Starknet's main oracles, pricing collateral and liquidations for lending protocols holding tens of millions on-chain. This post explains how a missing access-control check could have let anyone disable its core price feeds for a few cents.

All Roads Lead to Panic: A Starknet Oracle Story

Pragma is one of Starknet's most deeply integrated oracles, powering liquidations and collateral pricing across the chain's main lending protocols. This post describes a vulnerability that would let any anonymous attacker permanently disable its price feeds, for a couple of cents. One transaction. No flash loan. No governance manipulation. No special permissions. Just a function call that someone forgot to lock, and an append-only list that once written to, cannot be unwritten.

What is Starknet?

Starknet is a ZK rollup on Ethereum that uses STARK proofs to settle batches of transactions on L1 at significantly lower cost. Smart contracts on Starknet are written in Cairo, a programming language designed from the ground up for provable computation.

What is Pragma?

Pragma is the native oracle on Starknet, built in Cairo rather than ported from another chain. While most oracles aggregate prices off-chain and push a signed result on-chain, Pragma aggregates prices entirely on-chain: raw data is pushed directly by whitelisted market makers and exchanges, and the aggregation logic runs in the smart contract itself. The result is a price feed that inherits the same verifiability and transparency guarantees as the contracts consuming it.

Beyond standard spot price feeds, Pragma supports multiple aggregation methods per feed, including median, TWAP, and volume-weighted pricing, letting protocols choose what works best for their use case. It also offers computational feeds for more sophisticated inputs: a fully on-chain yield curve oracle and a volatility feed covering realized and implied volatility, designed for use in lending protocols and options vaults.

Pragma’s integrations include zkLend and Nostra, the two largest lending protocols on Starknet, as well as Vesu, a modular money market, and Carmine Options. As of April 2026, Pragma secures around $35M in total value on-chain, with recent highs closer to $110M (DeFiLlama).

All of this was at risk.

The Unlocked Door

Pragma's oracle contract includes a specialized feature for tokenized vault pairs, assets like CONVERSION_xSTRK/USD whose price is derived from an ERC4626 vault's exchange rate rather than direct market feeds. 

To support this, the contract maintains a list of pairs that should be routed through vault-based pricing logic. Adding a pair to that list is handled by add_registered_conversion_rate_pair:

// @notice add a new pair to the list of registered conversion rate pairs
// @param new_pair_id: the pair id to be added
fn add_registered_conversion_rate_pair(
    ref self: ContractState,
    new_pair_id: felt252
) {
    let mut registered_pairs =
        self.conversion_rate_compatible_pairs.read();
    registered_pairs.append(new_pair_id);
}

This function is exposed in the IOracleABI interface as a public external function, callable by any address on the network. No access control. No admin check. No validation that the pair actually has a vault behind it. No enforcement of the CONVERSION_ naming convention that every legitimate vault pair follows. It accepts any felt252 and appends it to the list, unconditionally.

For contrast, add_currency, an adjacent function with considerably less destructive potential, correctly requires admin access:

fn add_currency(ref self: ContractState, new_currency: Currency) {
    OracleInternal::assert_only_admin();
    // ...
}

The same pattern holds for upgrade() and every other sensitive function in the contract. The omission in add_registered_conversion_rate_pair stands out all the more because of it. The call to assert_only_admin() was right there, already in use everywhere else, and simply never added here.

All Roads Lead to Panic

Once a pair is on the conversion rate list, the damage happens quietly inside get_data(). When a price request comes in, the contract checks whether the requested pair is on the list:

let registered_conversion_rate_pairs =
    self.conversion_rate_compatible_pairs.read().array();
if registered_conversion_rate_pairs.contains(pair_id)
    || aggregation_mode == AggregationMode::ConversionRate {
    get_conversion_rate_price(self, data_type)
}

If it is, the contract routes unconditionally through get_conversion_rate_price(), which immediately asserts that a registered ERC4626 vault exists for the pair's quote asset:

let tokenized_vault = self.get_tokenized_vaults(quote_asset);
assert(
    tokenized_vault.vault_address != starknet::contract_address_const::<0>(),
    'No pool address for given token'
);

This makes sense for tokenized vault pairs. Assets like CONVERSION_xSTRK/USD represent shares in a liquid staking vault - their value isn't set by open market trading but by the vault's internal exchange rate, i.e. how much STRK you get back per xSTRK. Fetching a spot price for them would be meaningless; you need to query the vault contract directly. The conversion rate path exists precisely to handle this, and for those pairs it works fine since the vault is registered alongside the pair.

For standard market pairs like ETH/USD or BTC/USD though, no vault exists, the assertion fails, and the call panics. Every time. There is no fallback path, no second opinion, no graceful degradation.

The attack writes itself: call add_registered_conversion_rate_pair('ETH/USD'), and from that point on every price query for ETH/USD panics. Repeat for BTC/USD, STRK/USD, or any other pair - one transaction each, pocket change per feed.

No Way Back

What makes this more than a run-of-the-mill DoS is that there is no built-in recovery.

The conversion_rate_compatible_pairs storage variable is a List<felt252> from the Alexandria library. Only three references to it exist across the entire codebase: the storage declaration, the read-only getter, and the append-only setter. There is no removal function. The list only grows.

Calling upgrade() doesn't help either. It swaps the implementation class hash but leaves the storage state untouched. The malicious entries survive the upgrade, sitting there patiently, redirecting every query into a panic.

The only path to recovery: write a new contract implementation with a removal function, test it thoroughly enough to be confident it doesn't introduce new problems, deploy it via upgrade(), then use it to clean up the list. An attacker spends $0.10. Recovery requires non-trivial engineering effort under pressure, careful testing on a live system, and carries its own risk of introducing new bugs along the way.

Blast Radius

The downstream failures from disabling core pairs would be immediate across any protocols relying on the affected feeds:

  • Lending protocols cannot calculate collateralization ratios or execute liquidations. Underwater positions accumulate bad debt with nothing to clear it.
  • DEXs relying on Pragma for pricing sanity checks or TWAP calculations cannot safely execute swaps.
  • Derivatives platforms cannot mark positions to market, process settlements, or trigger liquidations. Traders cannot manage risk.
  • Yield protocols cannot rebalance or calculate returns, potentially trapping user funds.

The contract is deployed and verifiable at 0x2a85bd... on Voyager, where add_registered_conversion_rate_pair is visible in the ABI with "state_mutability": "external". A proof of concept confirming the complete attack vector was included with our disclosure.

The Fix

One line:

fn add_registered_conversion_rate_pair(
    ref self: ContractState,
    new_pair_id: felt252
) {
    OracleInternal::assert_only_admin();  // that's it
    let mut registered_pairs =
        self.conversion_rate_compatible_pairs.read();
    registered_pairs.append(new_pair_id);
}

A more hardened version would also validate that a vault has actually been registered for the pair before appending it, so that even a legitimate admin call can't accidentally poison the list. Adding a removal function to the admin surface would mean that if something does go wrong, recovery doesn't require a full contract upgrade under pressure.

Conclusion

The access control omission here was almost certainly an oversight. Every other sensitive function in the contract has it. But the reason this particular oversight is so severe comes down to two compounding factors:

The first is the routing logic. A registration function that silently redirects all future price queries for a pair, with no fallback, no validation, and no visibility to callers turns a simple append operation into a kill switch. The damage isn't visible at the point of the malicious call; it surfaces later, across every downstream protocol, when they try to fetch a price and panic instead.

The second factor causing severity to compound is the storage design. Append-only state with no admin escape hatch converts what would otherwise be a recoverable misconfiguration into a permanent one. Any storage variable that controls critical routing behaviour should have a corresponding removal path, even if it's admin-gated and expected to never see use.

Together, these are what make the attack economics so severe. An attacker spends $0.10 and walks away. Recovery requires engineering effort under pressure, careful testing, and a contract upgrade—during which time lending markets can't liquidate, traders can't close positions, and users can't withdraw.

This vulnerability was reported to the Pragma team through responsible disclosure. They responded quickly, shipped a fix, and minimized the amount of funds ever at risk.

Get The Latest

Subscribe to be notified whenever we publish new security research.

Great! Check your inbox and click the link.
Sorry, something went wrong. Please try again.