Pre-Mortem is a weekly series by Kostas Ferles, CTO of AuditHub. This article looks at a low-level call built from a caller’s router and data, and the static pass that catches the shape before deployment. New breakdown every week. Find Kostas at @KFerles.
The audit firm Veridise just published the auditor’s view of this bug: its CEO, Jon Stephens, traces the malicious calldata back from the low-level call by hand (the Auditor’s Take). This post takes the same bug from the AuditHub side, where that trace becomes a check that runs on its own. The contract that drained Arcadia built a low-level call out of a router and calldata its caller supplied, ran it without checking it, and did all of that behind an access-control guard that passed. What catches a call like that before it ships, and what keeps catching it after the next change lands?
TL;DR
- Arcadia’s rebalancer ran a caller-chosen router with caller-chosen calldata, no validation.
- Running from a trusted contract, the call moved funds from accounts the attacker never owned.
- The bug is a structural shape, readable in the source without running anything.
- A custom Vanguard detector matches that shape and fails the commit that introduces it.
A Guard on the Caller Says Nothing About the Call
Let me start with the shape, since it is simpler than the exploit built on top of it. A contract holds some privilege. It takes an address and a blob of calldata from whoever called it, and it runs that call. If nothing checks the address or the blob, the caller has handed the contract an instruction it will carry out under its own identity.
Think of a signed check. One made out for a set amount to a named payee is safe to hand over. A blank check that is already signed is not, because whoever holds it decides what it does. A low-level call built from unchecked caller input is the blank check. The guard on the function decides who gets to hold the pen. It says nothing about what they write.
Arcadia’s rebalancer was this exact shape. A user picks the router and hands over the swap calldata, and the contract makes the call with both. The Auditor’s Take walks the full drain, so I will not repeat it here. For detection, the shape is the whole story, because the shape is what a tool can catch.
What a Static Pass Catches
This bug has a structural signature, and that is what makes it a job for static analysis. You do not need the contract to reach any particular state to see the problem. Read the code and it is right there: an argument the caller controls flows into the calldata of a low-level call, and no require ever constrains it. A tool that reasons about the source, without running it, can find that across every function in the codebase.
A generic linter will not, and it is worth being precise about why. The require that should guard the call does not have to sit in the same function as the call. Spread across a rebalancer and its swap logic, the check and the call can live several functions apart. Most off-the-shelf scanners reason inside one function at a time, so they lose the pattern the moment it crosses a boundary.
Vanguard, our static analyzer, is built to follow data across those boundaries. A custom detector, written in its query language, encodes the shape directly:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
FIND Function f, ExternalCall call IN f, Value calldata IN call.calldata, Argument arg IN calldata.backwardSlices, WHERE call.isLowLevelCall, arg.function == f, !(EXISTS RequireLike r IN arg.forwardSlices || EXISTS RequireLike r IN arg.interprocForwardSlices) AS affectedFn = f, vulnerableCall = call, argument = arg |
Read it as a sentence. Find a low-level call, take its calldata, and slice backward to see where the bytes come from. If they trace to an argument the caller supplied, check whether any require constrains that argument, in this function or in one that reaches it. If nothing does, flag it. Run on Arcadia’s swap path, it returns the forwarding call, and the finding names the function, the call, and the unvalidated argument, so a developer sees exactly what to look at.
Here is the guarantee, stated carefully, since this is where tools get oversold. If the detector reports nothing, then within the shape it models, no low-level call in the codebase forwards unvalidated caller calldata. That is a statement about structure, not about exploitability. A flagged call is not automatically a drained protocol, and confirming that takes exercising the code, which is a separate step. What the detector settles is the cheap, high-value thing: this shape does not reach production unnoticed.
From One Review to Every Commit
What makes a detector like this worth having is when it runs, not only what it finds. A review, manual or automated, certifies the code as it stood on the day it was reviewed, but the code keeps moving after that. A forwarding call like Arcadia’s is the kind of change that lands later: a new integration, a refactor of the swap path, a helper that passes calldata through one more layer. Each reads as reasonable on its own, and each can reintroduce the bug.
This is the point-in-time problem that continuous security is built to close. The same detector belongs in the development workflow, running on every push and every pull request, so the commit that forwards unvalidated calldata fails review before it merges, with the finding in front of the developer who wrote it. A check that runs once protects one commit. A check that runs on every commit protects the codebase as it changes.
That matters more for this class than for most, because the guard on the function is not where the danger sits. A reviewer skims a privileged function, sees the access-control check, and moves on, which is the right instinct for most functions. The detector does not skim. It follows the data every time.
Want this caught before it ships?
If your contracts build low-level calls out of router addresses and calldata a caller supplies, this detector runs the same way on your code. Connect your repository and it flags the shape on every commit, before it reaches production.