Value deltas for the max search
I have been using ripfuzz to search for
high-value sequences with ripfuzz max. Coverage tells the fuzzer which code
is new. The objective is value(), a view function the harness already
exposes. For a long time the search only looked at that number at the end of
the sequence.
That is the wrong number for a lot of exploits.
The ladder
DRLVaultV3 is a USDC/WETH Uniswap V3 rebalance vault
(0x6A06707ab339BEE00C6663db17DdB422301ff5e8).
On 10 Nov 2025 a whitehat drained it in one Ethereum
transaction,
block 23,769,387.
swapToWETH is permissionless. Its slippage floor is a live QuoterV2 quote of
the same 0.01% USDC/WETH pool the swap executes against, minus 0.5%. Crash the
pool, the minimum out crashes with it.
Verichains
called that a self-referential slippage check.
The onchain sequence, all inside a Morpho flash loan of 13,980,773 USDC:
- Dump the loan through 1inch into the pool: 13,980,773 USDC → 812.95 ETH. WETH in pool terms moves from about 3,600 to about 865 million.
- Call
DRLVaultV3.swapToWETH(100000 USDC). The vault sells its full 100,000 USDC and receives 0.0001205 WETH (about $0.43). At the honest price that 100k should have bought about 27.8 WETH. - Restore: spend 780 WETH through 1inch for 13,959,481 USDC, top up 5.83 WETH for 21,291 USDC, repay Morpho to the wei.
The vault’s 100k USDC stays in the pool during the crash and comes back to the attacker on the round trip. That is the ladder: dump, force the vault to trade, unwind.
The ripfuzz campaign measures the same shape with value() after each handler.
reducePrice is the dump, swapToWETH is the vault, increasePrice is the
unwind:
baseline 5008 ETH
after reducePrice 923 ETH delta -4085
after swapToWETH 923 ETH delta 0
after increasePrice 5035 ETH delta +4112The campaign profit is 27 ETH. The path to it is a 4000 ETH hole. If you rank
prefixes by current value, reducePrice looks like damage. The fuzzer throws
away the mandatory first step.
Profit is not monotonic. Exploit trajectories usually dip before they pay out.
Value after every call
The worker now executes the sequence and calls value() after every handler:
[call_1, value(), call_2, value(), call_3, value()]That yields V0, V1, ..., Vn, where V0 is the baseline after setup and V_i
is the value after the first i calls. The delta of call i is
d_i = V_i - V_{i-1}.
value() is a view. It reads a handful of balances. A typical handler runs
swaps, router hops, or accounting. The extra views at most double the number of
EVM calls and add a small fraction of the gas. That price buys the only thing a
search over stateful sequences needs: which call moved the objective, and by
how much.
Two rules follow.
- Never prune on current value. A prefix that is far below the baseline may be the entry point of the exploit.
- A large negative delta is evidence, not damage. Value moved, so the state changed economically. The size of the dip is a signal even when the call reverts or the value never comes back.
Records, recoveries, activity
Each delta is classified. A class is a (prefix signature, final handler)
pair: the selectors before the call, plus the handler that just ran. Cheap to
compute, stable across runs. One min and one max per class, never a
history.
| Signal | Meaning | Fuzzer action |
|---|---|---|
new record min d | largest drop ever seen for this class | admit the prefix |
new record max d | largest gain ever seen for this class | admit the prefix, weight up |
| recovery | below baseline and d > 0 | admit the prefix |
d == 0 | the measured wallet did not move | neutral, neither pruned nor admitted |
The records are a poor man’s potential estimate. Instead of learning
P(future profit | state) with a model, the fuzzer keeps the extreme deltas
per class and treats a new extreme as novelty. A call that sets a new record
changed the objective more than any call of its kind before it. That is the
state a search should keep exploring from.
The interest check is a disjunction. Coverage still admits new code. A local best still admits a climb. Deltas sit next to those:
let interesting =
update.is_interesting() || beats_local || observation.is_interesting();observation.is_interesting() is a new min, a new max, or a recovery.
Corpus sampling weights entries by delta activity, not by current value. Activity is zero for a flat call. Otherwise it scales with the magnitude bit length, so a large dump is drawn more often than a one-wei nudge. Ranking the current value would kill the dip again. Ranking the movement does not.
When a sequence sets a new best, every prefix of it becomes a corpus entry, each carrying its measured value. That is the payoff of measuring the full trajectory. The fuzzer learns not just which sequence won, but which of its prefixes built toward the win, and it keeps every one of them for later extension.
The in-repo Ladder
I added Ladder and LadderWithNoise under fixtures/maxer/challenges. They
replay the same 5008 → 923 → 923 → 5035 path without the vault:
function reduce() external {
require(step == 0);
step = 1;
wallet = 923;
}
function swap() external {
require(step == 1);
step = 2;
}
function increase() external {
require(step == 2);
step = 3;
wallet = 5035;
}
function value() external view returns (uint256) {
return wallet;
}reduce is the dump. swap is the flat middle rung. increase is the
recovery. The highest value is 5035, and only that exact order reaches it.
LadderWithNoise is the same ladder plus twenty unused handlers, so the search
has to keep the dip while noise writes try to knock it off the path.
make challenges reaches 5035 on both.
The flat rung
Delta signals only see movement in the measured wallet. The DRLVaultV3 middle
rung has d = 0: onchain, swapToWETH moves 100,000 USDC from the vault into
the pool and the attacker wallet is unchanged. Ladder.swap is the same shape.
No delta class fires on the state the ladder needs.
The rung is not lost, because admission is a disjunction.
- Coverage. If the flat call exercises code the campaign has not seen from
earlier states,
update.is_interesting()keeps it. Record rungs keep the dip base alive from the moment it is first sampled, so the flat extension is attempted early, while coverage is still fresh. - Retroactive retention. After the first win, the winning sequence’s prefixes, the flat rung among them, land in the corpus with their measured values.
- Surrounding records.
min donreduceand the recovery onincreasekeep the ends alive even when the middle rung admits nothing on its own.
A zero delta is also genuinely ambiguous. An approval is flat and enables later profit. A failed call is flat because nothing happened. The layer treats zero as neutral: neither pruned nor admitted by delta alone. Enabler detection needs a different signal than value. That is out of scope here.
What I did not ship
I did not add a potential score:
score(s) = value(s) + lambda * estimated_future_profit(s)The challenge suite did not demand it. Corpus sampling already weights by delta activity. Lambda and depth decay only matter if that score is added. I will revisit if a ladder appears that activity weighting cannot keep alive.
Records also do not retire mid-campaign. An early extreme stays the bar. One
min and one max per class, never a history. Novelty resets between
campaigns because the records are not persisted.
No harness changes. Same value() the campaign already had. One extra view per
handler call.
That is the whole change: measure after every call, keep dumps as evidence, keep recoveries as exploit path, keep every prefix of a new best, and stop ranking the hole as if it were the answer.
| Tags | ripfuzz , fuzzing , solidity , security , defi , evm |
|---|