← All posts

Threat Research

Freezing a six-figure crypto vault

A few percent of disagreement between two contracts can freeze every deposit and withdrawal in a live crypto vault — and this one went unnoticed for six weeks.

Trident Research··10 min read

Most security bugs are a story about someone getting in. This one is the opposite: it's about everyone getting locked out. No attacker, no stolen keys, no clever exploit — just two pieces of code that each looked perfectly fine on their own, and a small disagreement between them that, on an ordinary day, could freeze a whole vault full of other people's money.

We ran into it while auditing a live DeFi vault — the kind of contract that takes deposits, earns yield, and pays it back out. Buried in its plumbing, one contract was allowed to produce a number that a second contract, sitting right next to it, was built to reject. Each was reasonable in isolation. Put together, they meant an unremarkable dip in the market could make every deposit and every withdrawal start failing at once. And it had been sitting there, live and quietly exposed, for about six weeks.

That is exactly what makes it worth walking through. There is no daring exploit to admire and no villain to point at — only a gap of a few percent between two honest-looking pieces of code. It is the kind of flaw that walks straight past a normal review, precisely because nothing about it looks wrong. So let's take it apart, piece by piece.

The setup

A little context first. This is a risk-tranching vault: it splits depositors into two groups — a senior tranche that takes less risk for a steadier return, and a junior tranche that takes on more risk for more upside. To divide the yield fairly between them, the vault has to know one number at all times: the current interest rate, or APR.

That number comes from a component called a rate feed— think of it as the vault's thermometer for yield. It works two ways. Most of the time, a trusted operator (a "keeper") posts a fresh reading on-chain and the feed simply hands that back. But if there is no recent reading, the feed falls back to calculating one itself, on the spot, from a helper contract called a provider — and before it trusts that freshly computed number, it runs a quick sanity check:

1int64 constant RATE_MAX = 2e12; // +200%, 12-dp fixed point (1e12 = 100%) 2int64 constant RATE_MIN = -0.5e12; // -50% 3 4function ensureValid(int64 rate) internal pure { 5 require(RATE_MIN <= rate && rate <= RATE_MAX, "InvalidRate"); 6}

A couple of things to read off that snippet. Rates are stored as signed, 12-decimal fixed-point numbers, so 1e12 means 100% and -0.5e12 means -50%. The number is allowed to go negative on purpose: if the underlying asset lost value over the period, the yield really was negative for that stretch, and the vault is built to cope with it. Taken on its own, ensureValid is exactly the guardrail you would want — it quietly refuses anything absurd. The trouble only shows up when you ask where its limits came from.

Where the two contracts disagree

The number ensureValidis checking doesn't come from nowhere. The provider works it out from how much the asset's price-per-share moved between two oracle readings. Since those readings are only a short time apart, it annualizesthe move — scaling a short-term change up to a yearly figure, the same way a 0.1%-a-day drift might get quoted as "about 36% a year." Then, just like the feed, it applies its own bounds before handing the number over:

1int256 constant BOUND_MIN = -1e12; // -100% 2int256 constant BOUND_MAX = 1e12; // +100% 3 4// annualize the per-share change across the gap between rounds 5int256 rate = (ppsChange * SECONDS_PER_YEAR * 1e12) / ppsPrev / deltaT; 6 7// SECONDS_PER_YEAR = 31_536_000; a ~1-day round is deltaT ~ 86,400s, 8// so a per-share decline of ~0.137% annualizes to about -50% 9if (rate < BOUND_MIN || rate > BOUND_MAX) return 0; // clamp only the extremes 10return int64(rate);

And there it is, hiding in plain sight. The provider will happily return anything from -100% to +100%, zeroing out only the truly wild values. The feed, meanwhile, only accepts -50% to +200%. For most readings those two ranges overlap and everyone is happy — but look at the bottom end. The provider's floor is -100%; the feed's floor is only -50%. There is a gap between them, and any number that falls into it is legal on one side of the handshake and forbidden on the other.

The dead zone

Any rate between -100% and -50% is, at the very same moment, a perfectly normal answer for the provider to give and an instant rejection at the feed. Both contracts are internally consistent. The bug lives entirely in the space between their two floors — a space neither file can see on its own.

Why one bad round freezes the vault

A disagreement like this would be harmless if the rejection happened somewhere quiet — an admin-only setting, say. It doesn't. The check lives inside the feed's latestRoundData(), the function that reports the current rate, and the vault's accounting calls it on every single action that moves money — with no safety net (no try/catch) to soften a failure:

1Vault.withdraw() // and deposit(), redeem(), cooldown-finalize 2 -> Accounting.sync() 3 -> rateFeed.latestRoundData() // no try/catch anywhere on this path 4 -> provider.getRate() => -0.6e12 // <- legal for the provider 5 -> ensureValid(-0.6e12) => revert "InvalidRate" // <- fatal at the feed

Follow the dominoes. The instant the annualized rate lands in that dead zone, ensureValid throws — which kills latestRoundData(), which kills the accounting update, which kills the user's transaction. And because deposits, withdrawals, and redemptions all run through that same path, they stop working together, all at once — not one function, the entire vault. What turns a single rejected number into a frozen vault is one quiet design choice: the feed reverts instead of clamping. Its own downstream code already knows how to handle a too-low rate gracefully (it just floors it to zero) — but the feed slams the door before that code ever gets a say. The stricter contract wins, and it wins on the busiest path in the whole system.

How little it takes to trigger

Here is the part that should make a protocol designer uneasy: because the rate is annualized, the market move needed to hit that -50% floor is genuinely tiny. Over a single day, a drop of about 0.137%in the asset's price-per-share is enough to annualize past the line. For a yield-bearing real-world asset or a staking token, a daily wobble that small — a fee, a routine revaluation, a minor hiccup — is completely ordinary. Nobody has to attack anything; the market trips the wire by itself, and the freeze is just the side effect.

There is a genuinely strange twist here, too. A truly catastrophic crash — worse than -100% in one round — gets clamped to zero by the provider and passes the feed without complaint. It is only the moderate, believable declines that produce a real negative number in the danger zone. Put plainly: the vault shrugs off the disaster and freezes on an ordinary bad Tuesday.

What actually breaks

So what does this look like for the people with money in the vault? While the asset sits in that band, the damage is total — every normal way in or out is closed:

  1. Every deposit, withdraw, redeem, and cooldown finalization reverts. The ordinary doors are all locked.
  2. The "break glass" options don't help either: the privileged reserve and rescue functions run through the same accounting update, so they revert too. There is no admin lever that quietly moves funds to safety.
  3. The only real cure is a keeper pushing a fresh, in-range rate — which is exactly the thing least likely to go smoothly during the market stress that caused the freeze in the first place.

The freeze does lift by itself once the rate climbs back above -50%, so on paper it is only "temporary." But temporary here means it switches on at exactly the moment people most want out, and stays on until the market decides to cooperate. Everyone in the vault, senior and junior alike, is stuck at the same time.

In the vault we looked at, that was a low six-figure balance sitting behind the freeze — and the mismatch had already been live on-chain for roughly six weeks before anyone flagged it. Six weeks in which one unremarkable down day would have locked every depositor out of their own money.

The fix

The reassuring part is that the fix is genuinely small — the hard bit is noticing it is needed at all. There are two clean options. Either widen the feed's accepted range so it covers everything the provider can legally return, or — the better choice — have the feed clamp an out-of-range value to its nearest limit instead of rejecting it outright, which is exactly what the code downstream already does with it:

1// option A: align the bands (the feed's floor must be <= the provider's floor) 2int64 constant RATE_MIN = -1e12; 3 4// option B (preferred): clamp on read instead of reverting, 5// mirroring what the downstream consumer already does with the value 6if (rate < RATE_MIN) rate = RATE_MIN;

While you are in there, check the ceiling as well — the very same bug can hide on the upside from a single typo. A provider whose maximum is written as 200 * 1e12 (that is, 2e14) paired with a feed that caps at 2e12 is off by a factor of a hundred, and it would freeze the vault on a big positive rate instead of a negative one.

Why a normal review misses it

It is worth sitting with why something this consequential is so easy to walk past. Every file here passes its own review with flying colors. ensureValid is a textbook bounds check; getRate is a sensible annualization with sensible guards. The bug is not inside either contract — it is in the handshake between them, and a reviewer reading one file at a time simply never sees both floors next to each other.

The mental model that catches it: a numeric range is a promise between two pieces of code. If one contract can produce values across some range, then everything that consumes those values has to accept that same range — or make a deliberate decision about the difference. And these promises drift most easily at the boundaries that never show up in a function signature: the exact minimum and maximum, the number of decimals, whether negatives are allowed, the units. When they drift, the stricter side quietly turns a legal value into a crash.

Two habits catch it in practice. First, whenever a value crosses from one contract to another, line up the assumptions on both sides — the ranges, the scaling, the sign — not just the logic. Second, fuzz the edges: aim tests squarely at the line between what a function accepts and what it rejects, because tests built around the values you expectwill never wander into the narrow band where a legal number suddenly isn't.

Reproducing it

If you want to watch it break for yourself, here is a small, self-contained Foundry test. It builds a stripped-down provider and feed with the same mismatched floors, plus a stand-in vault whose withdrawal reads the feed. A flat, boring round sails through; a single ordinary down-round pushes the computed rate into the dead zone, and the read that every withdrawal depends on reverts — freezing this toy vault exactly the way the real one would.

1// SPDX-License-Identifier: MIT 2pragma solidity ^0.8.24; 3 4import {Test} from "forge-std/Test.sol"; 5 6/// Computes an APR from the change in price-per-share between two oracle rounds. 7/// Its own guard band is [-100%, +100%]; anything outside is snapped to 0. 8contract RateProvider { 9 int64 constant BOUND_MIN = -1e12; // -100% 10 int64 constant BOUND_MAX = 1e12; // +100% 11 uint256 constant SECONDS_PER_YEAR = 31_536_000; 12 13 int256 public ppsPrev = 1e18; 14 int256 public ppsNow = 1e18; 15 uint256 public deltaT = 1 days; 16 17 function setRound(int256 prev, int256 cur, uint256 dt) external { 18 ppsPrev = prev; ppsNow = cur; deltaT = dt; 19 } 20 21 function getRate() public view returns (int64) { 22 int256 change = ppsNow - ppsPrev; 23 int256 apr = (change * int256(SECONDS_PER_YEAR) * 1e12) / ppsPrev / int256(deltaT); 24 if (apr < BOUND_MIN || apr > BOUND_MAX) return 0; // clamp only the extremes 25 return int64(apr); 26 } 27} 28 29/// Validates on the pull path — and its band is NARROWER than the provider's. 30/// That single mismatch is the whole bug. 31contract RateFeed { 32 int64 constant RATE_MAX = 2e12; // +200% 33 int64 constant RATE_MIN = -0.5e12; // -50% <- higher floor than the provider 34 35 RateProvider public immutable provider; 36 constructor(RateProvider p) { provider = p; } 37 38 function ensureValid(int64 rate) internal pure { 39 require(RATE_MIN <= rate && rate <= RATE_MAX, "InvalidRate"); 40 } 41 42 // pull path: recompute live and validate. Every read goes through here. 43 function latestRoundData() external view returns (int64) { 44 int64 rate = provider.getRate(); 45 ensureValid(rate); 46 return rate; 47 } 48} 49 50/// Stand-in for the vault: every value-moving call reads the feed first. 51contract Vault { 52 RateFeed public immutable feed; 53 constructor(RateFeed f) { feed = f; } 54 55 function withdraw() external view returns (int64) { 56 return feed.latestRoundData(); // reverts here => the withdrawal reverts 57 } 58} 59 60contract RateFeedFreezeTest is Test { 61 RateProvider provider; 62 RateFeed feed; 63 Vault vault; 64 65 function setUp() public { 66 provider = new RateProvider(); 67 feed = new RateFeed(provider); 68 vault = new Vault(feed); 69 } 70 71 // Flat round: 0% APR, well inside the feed's band. Withdrawals work. 72 function test_benignRound_withdrawSucceeds() public { 73 provider.setRound(1e18, 1e18, 1 days); 74 assertEq(vault.withdraw(), int64(0)); 75 } 76 77 // One ordinary down-round: pps falls ~0.164% in a day => ~ -60% annualized. 78 // Legal for the provider, but below the feed's -50% floor => the vault freezes. 79 function test_moderateDecline_freezesVault() public { 80 int256 prev = 1e18; 81 int256 next = prev - (prev * 16_438) / 10_000_000; // -0.16438%/day 82 provider.setRound(prev, next, 1 days); 83 84 int64 produced = provider.getRate(); 85 assertLt(produced, int64(-0.5e12)); // below the feed's floor... 86 assertGe(produced, int64(-1e12)); // ...but inside the provider's own band 87 88 vm.expectRevert(bytes("InvalidRate")); 89 vault.withdraw(); // the read every withdrawal makes reverts 90 } 91}

Stay ahead of the next exploit

Trident finds chains like this one before attackers do — continuous web & API pentesting correlated with cloud attack-path analysis. Bring your cloud, application, identity, and data context to a working session with our team.