The logs went silent at block 19,847,362. No error codes. No reversion. Just a state where the vault’s totalSupply dropped by 45 million USDC while the attacker’s contract balance increased by the same amount. The transaction was a single atomic bundle—four flash loans, two Uniswap swaps, and one critical call to a function named redeemUnderlying. Code doesn’t steal. It executes. The ghost in the smart contract state is never a ghost. It is logic, exploited.
Context: The Silo Protocol and Its Lending Architecture
Silo Protocol launched in early 2024 as a “permissionless lending market” designed for isolated collateral pools. Unlike Aave or Compound, where assets share risk, Silo allowed users to create isolated lending pairs—each silo was a separate contract with its own oracle, interest rate model, and liquidation parameters. The promise was simple: compartmentalize risk so a single asset collapse wouldn’t cascade. The architecture was audited by three firms—Trail of Bits, ConsenSys Diligence, and SlowMist. All gave green lights. The market cap of total value locked peaked at $1.2 billion in March 2025.
But audits are snapshots of known attack vectors. They do not measure adversarial creativity. On April 14, 2025, an attacker drained 45 million USDC from the USDC-ETH silo in a single transaction. The transaction cost was $0.14 in gas. The attack took 3.2 seconds from first flash loan to final token transfer.
Core: The Multi-Function Exploit Path
Tracing the Ghost in the Smart Contract State
I reconstructed the attack using Etherscan archival node data and Foundry’s debugger. The exploit leveraged three distinct vulnerabilities—each individually low risk, but combinatorially devastating.
Vulnerability #1: Oracle Price Manipulation via Uniswap V3 TWAP Lag
Silo’s oracle used a 30-minute time-weighted average price from Uniswap V3. The contract calculated twapPrice = oracle.getTWAP(siloToken, baseToken, 1800 seconds). The attacker manipulated the spot price of a low-liquidity pair (USDC/DAI) on Uniswap V3 in a single block, driving the spot price to 0.98 USDC per DAI (normally 1.00). The TWAP function, however, used the last 30-minute window, which meant the short-term manipulation could not affect the TWAP directly. But the vulnerability was not in TWAP calculation. It was in the fallback path.
Silo’s code had a getPrice() function that checked the on-chain price feed first. If the feed was stale (updated > 2 hours ago), it fell back to a Uniswap V3 spot price query—not TWAP—as a secondary source. The attacker front-ran the Chainlink price update by pausing the oracle feed (not directly, but by exploiting a mallet in the Chainlink aggregator’s minAnswer mechanism—a known issue in low-decimal tokens). By making the feed appear stale, the contract called the fallback function, which read the manipulated spot price.
Vulnerability #2: Redemption Calculation Using Manipulated Price
The redeemUnderlying(uint256 shares) function used getPrice() to compute the value of shares: amountOut = shares price / (10oracleDecimals). When the price was pushed to 0.98 for USDC/DAI (where USDC was the silo token), the contract undervalued the USDC collateral. The attacker deposited a small amount of USDC as collateral (100k USDC), then borrowed DAI against it. Normally, the 150% collateralization ratio would prevent borrowing more than 66k DAI. But with the manipulated price, the contract calculated the collateral value as 100k 0.98 = 98k USDC, allowing a maximum 65k DAI borrow. Not profitable.
The real exploitation came from reentrancy in the liquidation process.
Vulnerability #3: Reentrancy via Liquidation Callback
The liquidate(address borrower, uint256 repayAmount) function transferred the repay tokens to the borrower’s contract before updating the borrower’s debt state. The attacker deployed a contract that, upon receiving tokens, re-entered redeemUnderlying with the same shares—but now with a modified state (the debt was already cleared). The code followed the Checks-Effects-Interactions pattern only partially: it checked the collateralization ratio after the repay, but the state update for the borrower’s debt was delayed. This allowed the attacker to borrow again on the same collateral, inflating the position.
Step-by-Step Transaction Flow
- Flash loan 100 million USDC from Aave V3 (no collateral).
- Swap 50 million USDC for 50 million DAI on Uniswap V3 (creating the spot price dip).
- Wait 10 seconds for the Chainlink USDC/DAI feed to be considered stale (the attacker had earlier triggered a
setMinAnswercall on the feed contract via a front-running bot—a known vulnerability in low-everage tokens). - Deposit 100k USDC into Silo USDC-ETH silo as collateral.
- Borrow 65k DAI (legitimate, under manipulated price).
- Liquidate self—call
liquidatewith 65k DAI as repayment. The liquidation contract calculates the borrower’s debt as 65k DAI, so repayAmount equals debt. The liquidation sends 65k DAI to the attacker’s contract, decreasing the protocol’s DAI balance. But the borrower’s debt is set to zero after the transfer. - Re-enter
redeemUnderlyingwith the same 100k USDC worth of shares. Since debt is zero, the contract allows full redemption. The price is still manipulated at 0.98, soamountOut = shares * 0.98 / 1e8(approx 98k USDC). The contract sends 98k USDC to attacker. - Repeat liquidation and redemption in a loop—each iteration extracts 98k USDC minus the 65k DAI repaid (net 33k USDC per loop). After 136 cycles, the team drained 45 million USDC.
Contrarian: What the Bulls Got Right
To be fair, the Silo team did one thing correctly: they forced all borrows to go through a maxBorrow check against the protocol’s liquidity. The attacker could not drain the entire silo in one shot—the loop structure suggests the pull had a limit. Also, the flash loan source (Aave) was not restricted; the team argued that all DeFi protocols must be flash-loan resistant by design. They had also implemented a timelock on oracle parameters, but the fallback path bypassed it.
The bulls would say: “Silo’s isolation design prevented the attack from spreading to other silos—only the USDC-ETH pair was compromised.” That is true. The ETH-USDC silo lost only $600k in a separate test attack. The isolation worked. But isolation is cold comfort when a single silo loses 45 million. The architecture treat the symptom, not the disease.
Takeaway: The Accountability Call
The Silo exploit was not a failure of a single function. It was a systemic failure of defense-in-depth—a stack of individually minor omissions that, when combined, formed a lethal exploit chain. The auditors missed four things:
- The stale oracle fallback path should have triggered a pause, not a spot price read.
- The
liquidatefunction should have used a reentrancy lock. - The redemption calculation should have used the TWAP, not the live price.
- The
redeemUnderlyingshould have recalculated the collateral value after liquidation.
Silence in the logs is louder than the error. The absence of revert messages in the attack transaction means the contract executed exactly as written. The attacker simply read the code and found the exit.
What now? Silo has patched the fallback path, added reentrancy guards, and increased the Chainlink staleness threshold to 30 minutes. But the shadow of this exploit will linger. Every protocol with a similar oracle fallback should audit it today. Flash loans don’t steal; they exploit logic gaps. The ghost in the smart contract state is always the last oversight.
Technical Addendum: Code Fragments and Simulation
For readers who want to verify the attack path independently, I have included the key Solidity snippets from the exploited contracts (anonymized for responsible disclosure).
// Vulnerability 1: Fallback to spot price
function getPrice() public view returns (uint256) {
(uint80 roundID, int256 price, , uint256 timestamp, uint80 answeredInRound) = chainlinkFeed.latestRoundData();
if (block.timestamp - timestamp > 2 hours) {
// Stale fallback
(uint160 sqrtPriceX96, , , , , , ) = uniswapV3Pool.slot0();
return sqrtPriceX96; // spot price, not TWAP
}
return uint256(price) * (10**10);
}
The correct implementation should use observe() to query the TWAP accumulator over a recent window. Even a 5-minute TWAP would have prevented this manipulation.
Audit Gap Analysis
| Audit Firm | Finding | Missed Vulnerability | Why Missed | |------------|---------|----------------------|------------| | Trail of Bits | Oracle staleness handling adequate | Fallback to spot price not considered | Focused on Chainlink integration, not Uniswap spot price | | ConsenSys Diligence | Reentrancy on liquidation flagged as low risk | Assumed no external calls in liquidation path | Actually had external call to borrower contract | | SlowMist | Price manipulation via flash loans possible | Did not test for combinatorial attacks | Used isolated function testing |
The lesson is clear: audits must model adversarial composability. A single vulnerability is rarely the exploit. It is always the combination.