Root Cause
DataStreamConsumer.verifyReport() validated the Chainlink DON signature but never enforced the report's expiresAt field. A real signed report bearing expiresAt = 2025-11-01 was accepted on 2026-09-16 — nearly 11 months after expiry. Signature validity and data freshness are orthogonal; the contract treated them as equivalent.
// Vulnerable pseudocode
function verifyReport(bytes calldata signedReport) external {
// ✅ Checks DON signature — proves provenance
address signer = recoverSigner(signedReport);
require(isValidDONSigner(signer), "bad signer");
// ❌ Missing freshness gate:
// require(report.expiresAt >= block.timestamp, "stale report");
// Price applied without age check
_applyPrice(report.feedId, report.price);
}
Because verifyReport() is external with no role gate, any address can call it with historical reports pulled from on-chain calldata.
Attack Steps
| Step | Action | Detail |
|---|---|---|
| 1 | Fund | Funded via Tornado Cash equivalent |
| 2 | Flash-loan | Borrowed 100 WHYPE from Morpho Blue on Hyperliquid EVM |
| 3 | Stale report replay | Called verifyReport() twice with valid DON-signed reports captured from historical calldata |
| 4 | Price manipulation | PRFI oracle price updated $0.0021 → $0.1101 (~52× increase) |
| 5 | Collateral deposit | Deposited PRFI at inflated valuation |
| 6 | Drain | Borrowed WHYPE against overvalued collateral, repaid flash loan |
| 7 | Profit | Net ~397–398 WHYPE extracted; 425.52 WHYPE total drained from reserve |
- Exploit tx:
0xff990876d863a61732779c341991215856c89420b84daaf31eece7ecd5ff4243 - Attacker:
0x19bc1c7fd4aa93f540498499b8f5b4fc3dde5a52
Impact
- ~425.52 WHYPE drained from PrimeFinance's lending reserve on Hyperliquid EVM
- PRFI token holders experienced artificial price distortion during the window
- Protocol paused further borrows after detection
- USD value unconfirmed (WHYPE price at block time not yet independently verified)
Lessons for Auditors
- Separate authenticity from freshness. A valid DON signature proves a report came from Chainlink's network — it says nothing about whether the data is current. The
expiresAtfield in Chainlink Data Streams exists for exactly this purpose; check it withrequire(report.expiresAt >= block.timestamp, "stale"). - Access-control
verifyReport(). If price updates must flow from a keeper or protocol-controlled bot, restrict callers rather than leaving the function fully permissionless. Any open price-push surface is an attack vector. - Audit historical calldata surfaces. On-chain calldata is public and permanent. If old transactions contain validly-signed payloads, assume adversaries will replay them.
- Pair oracle freshness with collateral factor limits. Protocols with thin long-tail asset markets should use tighter staleness bounds and lower LTV ratios to reduce the delta an attacker can arbitrage before detection.