Root Cause
A legacy DaiDripsHub deployment on Ethereum Mainnet (still holding live user funds despite being superseded) contained an unsafe uint128 → int128 downcast with no bounds check. In EVM two's-complement arithmetic, a uint128 value exceeding type(int128).max (≈1.7×10³⁸) becomes negative when reinterpreted as int128. The protocol's transfer logic used this signed value to determine direction of fund flow — negative meaning withdrawal — allowing an attacker to trigger arbitrary withdrawals from the DAI reserve.
// Vulnerable function in legacy DaiDripsHub
// Ethereum Mainnet — July 14, 2026
function give(address receiver, uint128 amt) external {
_give(msg.sender, receiver, amt);
}
function _give(
address sender,
address receiver,
uint128 amt
) internal {
// VULNERABILITY: unchecked cast — values above int128.max wrap to negative
int128 delta = int128(amt); // No require(amt <= uint128(type(int128).max))
_addToDrips(receiver, delta); // Records delta as collectable for receiver
// When delta < 0, _transfer treats it as a pull from the reserve
if (delta < 0) {
_transfer(daiReserve, uint128(-delta)); // Pulls DAI out
} else {
_transfer(sender, uint128(delta)); // Normal: pulls DAI from sender
}
}
Exploit construction: The attacker chose A = 2^128 - B where B = 24,882.995… DAI. int128(A) yields -B, which the transfer logic interprets as "pull B from the reserve." The give() call costs the attacker nothing — the send leg is skipped — while the reserve empties.
Attack Steps
| Step | Input | EVM Interpretation |
|---|---|---|
| 1 | Choose A = 2^128 - 24882995421947667857715 | Near max uint128 |
| 2 | Call give(attackerAddr, A) | Triggers _give() |
| 3 | int128(A) wraps → -24882995421947667857715 | Negative delta |
| 4 | _addToDrips(attacker, -B) records -B as collectable | Accounting flaw |
| 5 | _transfer(daiReserve, uint128(-(-B))) = _transfer(reserve, B) | 24,882.99 DAI exits reserve |
| 6 | Attacker collects 24,882.995421947667857715 DAI from hub | Full drain |
On-chain: the ERC-20 transfer sequence shows DAI moving daiReserve → hub → attackContract → attacker.
Impact
- Loss: 24,882.995421947667857715 DAI (~$24,883)
- Chain: Ethereum Mainnet
- Date: July 14, 2026
- Affected contract: Legacy
DaiDripsHub(deprecated but unfunded withdrawal left active) - CWE: CWE-190 (Integer Overflow/Wraparound); specifically an unsafe narrowing cast
- Severity note: Dollar loss is modest, but the pattern — sign-flip via downcast controls fund-flow direction — is high-severity in any contract with larger reserves
Lessons for Auditors
- Audit deprecated/legacy deployments if they hold funds — contracts that are "superseded" but not paused or drained are live attack surfaces. Include them explicitly in audit scope or require the team to pause them.
- Bounds-check every narrowing integer cast — before any
int128(x)wherexisuint128, assertrequire(x <= uint128(type(int128).max), "overflow"); or use OpenZeppelinSafeCast.toInt128()which reverts automatically. - Fuzz arithmetic boundaries — fuzz inputs at
type(int128).max,type(int128).max + 1, andtype(uint128).maxfor every function accepting unsigned integers cast to signed. - Sign semantics govern fund direction — wherever the sign of a value (positive vs negative) controls which party sends vs receives, treat the entire upstream type chain as a critical attack surface.
- Privilege the receiver path separately from the drain path — rather than using negative deltas to denote withdrawals, use two explicit function calls (
deposit()/withdraw()) with distinct access controls to eliminate sign-confusion bugs entirely.