Sitemap

Pulling Apart the Resupply Exploit

5 min readMay 24, 2026

--

Three “harmless” bugs that, together, drained a protocol of nearly $10 million.

0xffbbd492e0605a8bb6d490c3cd879e87ff60862b0684160d08fd5711e7a872d3

A single transaction. A single block. A flash loan and 1 wei of collateral in, nearly $9.6 million out. No compromised keys, no governance attack — just protocol logic executing exactly as designed, in a way the system never accounted for.

What Is Resupply Protocol?

Resupply is a stablecoin lending protocol where users deposit collateral and borrow reUSD against it. Instead of using plain tokens as collateral, the protocol accepts ERC-4626 vault shares—tokenized representations of assets deposited into yield-generating vaults.

At a high level, the flow looks like this:

The exploited market was the crvUSD-wstUSR pair. In this market, users could deposit shares of a newly deployed ERC-4626 vault called cvcrvUSD as collateral and borrow reUSD against them.

The critical detail: the vault was completely empty when deployed.

That created the exact conditions needed for an ERC-4626 inflation attack.

Three Bugs, None of Them “Critical”

What fascinated me about this exploit is that no single bug would alarm most auditors. I went through each one, and in isolation, I’d probably have flagged them as low-risk too. But they feed into each other in a way that’s genuinely elegant — if you can call a $10M theft elegant.

The empty vault problem

The cvcrvUSD vault used a straightforward share conversion—when the vault is empty, you deposit assets and get shares 1:1. Standard stuff:

function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
if (supply == 0) return assets;
return (assets * supply) / totalAssets();
}

OpenZeppelin solves this differently. They add a virtual offset—a small phantom amount of shares and assets that’s always present in the math, making it impossible to manipulate the ratio cheaply:

function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
return assets.mulDiv(
totalSupply() + 10 ** _decimalsOffset(),
totalAssets() + 1,
rounding
);
}

Without that offset, an attacker can deposit 1 wei to become the sole shareholder, then send a large donation directly to the vault’s address via ERC20.transfer(). That bypasses deposit()—no new shares are minted, but totalAssets() reads the contract’s balance, so it jumps. Now one share represents the entire donated amount.

Most auditors would flag this as low or informational. “Team should seed the vault at deployment.” Move on.

The division that rounds to nothing

Resupply converts between collateral value and borrow value using an exchange rate. The calculation is simple: take the oracle price and invert it.

function _updateExchangeRate() internal returns (uint256 _exchangeRate) {
uint256 oraclePrice = oracle.getPrices(collateral);
_exchangeRate = 1e36 / oraclePrice;
_exchangeRateInfo.exchangeRate = _exchangeRate;
}

After the vault inflation, the oracle — which derives price from the vault’s convertToAssets()—returns an enormous number. And here’s where Solidity’s integer math bites: there are no decimals. When the denominator exceeds the numerator, the result truncates to zero.

1e36 / (something larger than 1e36) = 0

Exactly zero. And the contract stores that zero without checking. On its own, an auditor might call this a medium-severity edge case. The oracle “should” return reasonable values. Under normal conditions, it would.

Get Tanu Gupta’s stories in your inbox

Join Medium for free to get updates from this writer.

The solvency check that trusts the rate

The protocol decides whether a borrower is solvent by computing their loan-to-value ratio:

function _isSolvent(address _borrower) internal view returns (bool) {
uint256 _ltv = (
_borrowAmount * _exchangeRate * LTV_PRECISION
) / EXCHANGE_PRECISION / _collateralAmount;

return _ltv <= _maxLTV;
}

Multiply the borrow amount by the exchange rate. If `_exchangeRate` is zero, the entire product is zero. LTV comes out as zero. And zero is always less than or equal to any maximum.

Borrow any amount. Put up any collateral. The check passes.

Alone, this seems like a non-issue — why would the exchange rate ever be zero? That’s “unreachable.” Low severity.

Press enter or click to view image in full size

Walking Through the Attack

Everything happens atomically, in a single transaction:

Setup — Flash borrow USDC from Morpho and swap it to crvUSD through Curve. Now the attacker has capital to work with, borrowed for free (minus a small fee).

Inflate — Deposit 1 wei into the empty cvcrvUSD vault, becoming the only shareholder. Then transfer a large amount of crvUSD directly to the vault contract. No new shares are minted, but totalAssets() now reflects the full donated balance. One share is suddenly worth far more than it should be.

Corrupt — Trigger the exchange rate update. The oracle reads the vault’s inflated share price. The rate calculation divides by this inflated value and truncates to zero. The exchange rate is now stored as zero.

Drain — Open a borrowing position with 1 wei of collateral. Borrow the maximum amount of reUSD available—the entire pool. The solvency check multiplies the borrow amount by zero, gets zero, and approves.

Exit—Swap the borrowed reUSD back to USDC, repay the flash loan. Walk away with the difference.

One block. Around 12 seconds. Roughly $9.6 million in profit.

The Same Pattern, Everywhere

After pulling apart Resupply, I started looking at other ERC-4626 exploits. The family resemblance is unmistakable.

  • Venus / wUSDM—Same inflation trick, different payout. An attacker donated into an ERC-4626 vault to inflate the exchange rate, then used that inflated rate to trigger a self-liquidation on Venus. The protocol absorbed the bad debt; the attacker walked away with the profit.
  • sDOLA / LlamaLend — Inflation plus oracle misuse. A large flash loan was donated into the sDOLA vault, spiking its exchange rate in a single transaction. LlamaLend’s oracle consumed the raw convertToAssets() value — no smoothing, no TWAP — so borrowers’ positions suddenly appeared underwater. The attacker liquidated them and collected the rewards.
  • ycdeal3 / RWAVault — A different angle but the same family. The vault overrode withdraw() and redeem() with custom logic that accidentally dropped the allowance check. An attacker could call withdraw() specifying someone else as the owner, burn their shares, and redirect the assets to themselves.

Every one of these shares a root cause: ERC-4626 vaults are used as price references by other contracts, and the assumptions baked into those contracts don’t hold when the vault is manipulated. The specific exploitation path varies—some inflate, some corrupt oracles, and some bypass access controls—but the underlying vulnerability class is the same.

What’s Next—Can we catch this entire class of bugs automatically?

In the next article, I’ll walk through building VaultGuard—a static analysis tool that detects ERC-4626 vault share manipulation vulnerabilities by looking for the same patterns we identified here: inflation vectors, unprotected divisions, oracle misuse, and solvency bypasses.

Next: Building VaultGuard: A Static Analyzer for ERC-4626 Exploits

This is Part 2 of a three-part series on ERC-4626 vault security. Part 1 covered the vulnerability class. Part 3 covers building the detection tool.

--

--