October 20, 2026
|
Exploit Postmortem
Cover image for "Drips Network's $25K Loss and How Olympix Would Have Prevented It"

Drips Network's $25K Loss and How Olympix Would Have Prevented It

Date: July 14, 2026

Chain: Ethereum

Loss: 24,882.99 DAI, about $24,883

Class: unchecked unsigned-to-signed integer cast that reversed a transfer

At 09:16:35 UTC on July 14, 2026, an attacker called a function named give() on an old Drips Network contract and asked to give away an amount of DAI that was 39 digits long. Nobody holds that much DAI, and nobody needed to, because the number was built so that the contract would read it backwards.

The give() call ended with the contract paying out instead of collecting. All 24,882.995421947667857715 DAI in the legacy DaiReserve, every last unit of it, went to the attacker in a single transaction. The contract converted an unsigned amount into a signed one without checking that it fit, and the sign of that number was the only thing deciding which way the money moved.

A Contract Built to Send Money Forward

Drips is an Ethereum protocol for funding open source software. Its documentation describes three ways funds can flow between users: streaming, which moves funds over time at a fixed per-second rate, splitting, which forwards a fixed fraction of whatever a user receives, and giving, which transfers an amount from one user to another immediately.

Giving is the simplest of the three, and the legacy DaiDripsHub exposed it as give(address receiver, uint128 amt). A caller names a receiver and an amount, the hub records that amount as collectable for the receiver, and the caller's DAI moves into the DaiReserve, the contract that actually holds the funds. That legacy deployment was still live and still holding DAI in July 2026, behind a proxy at 0x7304...d365.

A Sign That Decides the Direction

According to Verichains' analysis, give() hands off to an internal _give() function, which records the amount for the receiver and then calls _transfer() with the value -int128(amt). _transfer() reads the sign of what it gets. A positive amount means "pay the user", so the hub withdraws DAI from the reserve and sends it out. A negative amount means "collect from the user", so the hub pulls DAI from the caller and deposits it into the reserve. For an ordinary gift, int128(amt) is positive, its negation is negative, and the reserve takes DAI in.

The weak point is the cast. amt arrives as a uint128, an unsigned whole number that can never be negative and can go up to about 3.4 times 10 to the 38th. int128 is its signed cousin, with half that range on the positive side, because the top bit is spent on the sign. When Solidity converts a uint128 that's bigger than int128's maximum, it keeps the same 128 bits and simply reads them as a negative number. Solidity 0.8's checked arithmetic doesn't cover explicit conversions like this, so no error is raised.

That makes the teaching moment here simple: when a number's sign decides which way funds move, that number can never come straight from a caller without a range check, because a cast will happily turn a huge positive into a negative. A car odometer that rolls past its last digit back to zero gives the same effect, where going far enough forward lands you behind where you started.

One Number, Calculated to the Wei

Verichains lays out the arithmetic. The reserve held exactly B = 24,882,995,421,947,667,857,715 in DAI's smallest unit. The attacker passed A = 2^128 minus B, which is 340,282,366,920,938,438,580,379,185,484,100,353,741. A is a valid uint128, but it's larger than int128's maximum, so int128(A) comes out as minus B. The hub then negated it, producing plus B, and passed that positive number to _transfer(), which treated it as a withdrawal of the reserve's entire balance.

The attacker's wallet, 0x84dA...9CCE, sent the transaction to its attack contract, 0x00c6...4F12, in block 25529927. The Etherscan record shows three DAI transfers of 24,882.995421947667857715 each: from the DaiReserve to the DripsHub proxy, from the proxy to the attack contract, and from the attack contract to the attacker's wallet. Verichains notes that the attacker didn't bypass access control or manipulate an oracle. give() was public, and the only thing the attack needed was a number calculated against the reserve's balance at that block.

How Olympix Catches the Cast That Flips a Transfer

Olympix reads a contract the way an attacker does, as states, transitions and entry points, and takes in the context around the code to infer the rules the system relies on without stating them. In DaiDripsHub, the state that matters is the DaiReserve's balance, and give() is an open entry point that changes it. The engine maps the paths through that entry point and ranks them by signals, patterns in the code that mark a path as dangerous. The signal here is a narrowing cast from an unsigned to a signed type on a caller-supplied value with no bounds check, feeding a sign that decides the direction of a transfer. Olympix's static analysis surfaces that pattern, and Olympix has flagged this class before in other codebases.

The rule give() depends on fits in one sentence: a gift can move DAI from the caller into the reserve, and it can never move DAI out of the reserve to the caller. BugPoCer tests rules like that one with Counterexample-Guided Invariant Refutation, starting from what the whole system has to guarantee and working outward to the inputs that could violate it. Here it treats the DaiReserve's balance as the thing to protect and drives values through the flagged cast until that balance moves the wrong way. Any amount above int128's maximum reverses the direction, and an amount sized against the reserve's balance turns the reversal into a full drain.

The output is a counterexample with the concrete call, the minimal condition that triggers it (an amount above type(int128).max) and a generated Forge test that replays it. Set against the reserve balance, that counterexample is the July 14 transaction, produced in development instead of on mainnet. The engine runs automatically during development, at every merge and before release, so a legacy contract like this one gets the same standing check as the code a team shipped last week.

Old Deployments Keep Their Old Edges

The DaiDripsHub that lost these funds was a legacy deployment that was still live and still holding real DAI. Verichains' fix is short, validating that an input fits the positive range of the destination type before casting and representing transfer direction explicitly instead of inferring it from a sign, and it's the kind of rule a tool can enforce on every contract a team still has live. If you'd like to see the counterexamples Olympix produces for your own contracts, old and new, book a technical walkthrough.

On-Chain References

Protocol: Drips Network (legacy DaiDripsHub)

Chain: Ethereum

Date: July 14, 2026, 09:16:35 UTC

Loss: 24,882.995421947667857715 DAI (about $24,883)

Attacker EOA: 0x84dA...9CCE

Attack contract: 0x00c6...4F12

DaiDripsHub proxy: 0x7304...d365

Implementation at attack block: 0x8d32...f697

DaiReserve (drained): 0xf9bb...d44a

Exploit tx: 0xc38a...4130

Block: 25529927

Sources

Verichains: Drips Network, When Giving Became Receiving

SlowMist: Drips Network exploit alert

Drips Documentation: Protocol Overview

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.