Low-Level Call Hijacking: The Arcadia $3.5M Exploit

Low-Level Call Hijacking: How a Trusted Router Call Cost $3.5M

Aug 5

| 4 min read

*The Auditor's Take is a weekly series by Jon Stephens, CEO of Veridise. This one is about privileged contracts that forward caller-supplied data straight into a low-level call, and how a senior auditor catches the bug before it ships. New take every week, and you can follow Jon at @FormallyJon.* A protocol can define exactly which contracts may act on its users' behalf and still be drained. That permission carries an assumption with it: that the trusted contract will only ever act under the circumstances it was built for. When a single unprotected call lets an attacker drive that contract, the permission still holds and the assumption behind it does not. That is the shape of the bug that drained roughly $3.5 million from Arcadia Finance on Base in July 2025. **TLDR:** - A privileged **rebalance flow** passed a caller's router and calldata into an **unchecked call**. - The call ran as a **trusted manager**, free to move **other users' assets**. - A trusted contract was **compromised** without any **key or secret** being stolen. - A **Vanguard custom detector** flags the pattern: **unvalidated calldata** in a low-level call. ## When a Privileged Contract Forwards Data It Never Checked Protocols routinely give one contract power over another. A manager contract acts on behalf of a user, and other contracts authorize it so they can work together. In every case, the contract handing over that power is counting on the other to use it only as intended. Say the contract holding that power takes data from whoever called it and feeds that data to a low-level `call`. The expectation is then only as safe as the validation in front of it. Without that validation, the contract becomes a way to run arbitrary code under a trusted identity. So the first move on any low-level call is to identify what an attacker controls. What address is being called, and can they point it anywhere they like? What is in the calldata, and does anything validate it? This vulnerability is common enough to make those questions a habit. In Veridise's study of its own audit findings, logic and data-validation errors are the two archetypes behind the bulk of high-severity vulnerabilities. An unchecked call like this one is a data-validation failure. ## Arcadia: The Call That Came From Inside the Protocol Arcadia Finance is a DeFi protocol on Base. Users deposit collateral into an account contract and can borrow against that collateral. They can also authorize a manager contract to act on the account's behalf. The Rebalancer is one such manager. It adjusts a user's position by swapping tokens through a decentralized exchange, using a router address and swap calldata supplied by whoever initiates the rebalance. On July 15, 2025, an attacker triggered a rebalance on an account they controlled, using custom calldata that redirected the call to another user's account rather than the expected DEX contract. This attack drained about $3.5 million in assets from multiple users across several transactions. ## The Property the Swap Path Skipped Start with the rule the code should have enforced. A contract performing an external call for a user needs to establish two things. The target has to be a contract the protocol trusts, and the payload has to do no more than the operation the user asked for. The Rebalancer establishes neither. Here is the call, in `SwapLogic._swapViaRouter()`: ```solidity // SwapLogic._swapViaRouter() (bool success, bytes memory result) = router.call(data); ``` Both `router` and `data` are taken as input from whoever initiated the rebalance. The contract approves the input token to `router` before calling it. The intent was to invoke a real exchange such as Uniswap, but nothing in the code requires that the target be one. The `_swapViaRouter()` function is an internal function though (hence not callable by an attacker directly). The `rebalance()` function provides a convenient entry point as it will invoke `_swapViaRouter()` blindly. Spotting the unvalidated call is not typically the hard part. Working out what an attacker can do once they reach it is. The attack starts innocent enough as the attacker creates an account for themselves with a small LP position and pays the debt of all future victims. While this action may appear selfless in isolation, it was performed so that the victim’s collateral could be withdrawn without violating any health checks. With all victims clear of debt, all collateral is unlocked, allowing the attacker to drain everything. To perform the attack itself, `rebalance` is invoked via the `Rebalancer` on the attacker’s own account. The supplied `router` and `data` were crafted such that the exposed low-level call would execute privileged actions within *victim’s account* rather than an exchange as intended. Since the call was invoked by `Rebalancer` rather than the attacker, the access control checks pass as `Rebalancer` was an approved asset manager. This one unprotected low-level call therefore allowed an attacker to smuggle a malicious call into a context assumed to be protected and resulted in the theft of millions of dollars as user assets were withdrawn. ## How a Senior Auditor Traces a Low-Level Call The method that fits this bug is backward taint analysis, and it starts at the call. Low-level calls are a known source of exploits, so a senior auditor stops on every one and works backward from it. Which values decide the target and the payload, and what restrictions are placed on those values? The technique runs from the sink to the source. `router` and `data` decide what the call does, so follow both back through each assignment and each function that passes them along. In the Rebalancer, that chain ends at `rebalance()`, one call away, where they arrive as arguments from an external caller. Nothing in between restricts either value. That result is what makes the impact clear. An attacker who reaches the call decides what the Rebalancer invokes and what it sends. The account contract that authorized the Rebalancer places no restriction on what a manager may do, so authority granted for rebalancing covers any action a manager can take. That scope is the real defect the analysis exposes. ## From a Manual Review to a Check on Every Commit The property behind that analysis is a rule about the shape of the code, so it can be written down and run automatically. Doing it by hand does not scale. It has to be repeated for every low-level call the codebase contains, and the one that gets skipped is the one that matters. Vanguard, the static analyzer Veridise auditors use, encodes that rule as a custom detector. Stated plainly, it flags any low-level call whose calldata comes from an external-function argument with no `require` validating it. The check is interprocedural. The `require` can sit in a different function from the call, so the detector follows the data across function boundaries, the way the auditor does. The guarantee is narrow, and worth stating exactly. The detector reports where the pattern is, not whether a given instance is exploitable. Severity is still the auditor's judgment. What the detector guarantees is that the pattern stops shipping unnoticed. The companion Pre-Mortem covers what that looks like running on every commit. ## What Carries Beyond Arcadia The point here is bigger than routers and rebalancers. A contract acting for a user should not accept arbitrary data and forward it into a call on trust. It should decode the data, establish that the action is one it is permitted to take, and reject the rest. The same applies in reverse, to the contract granting the privilege. An account that authorizes a manager should limit what that manager is allowed to do, rather than trusting it with every action the account itself can perform. Look at the privileged functions in your own protocol with that in mind. Access control tells you who may invoke a function. What they can make that function do is a separate question. ### Working on a similar protocol? If your protocol has a privileged contract that forwards caller-supplied calldata into a low-level `call`, an attacker can use it to perform actions the protocol never intended, such as bypassing access control checks. The attacker does not have to break in. They only need to steer a contract you already trust. If you want a second pair of eyes before your next deploy, talk to us. ## The Takeaway Low-level call hijacking compromises a trusted contract without stealing a key or a secret. An attacker who controls the target and payload of a privileged contract's call can point its authority at other users' assets, which is how roughly $3.5 million left Arcadia's accounts. Catching this class means checking what restricts the values feeding every low-level call.

The Auditor’s Take is a weekly series by Jon Stephens, CEO of Veridise. This one is about privileged contracts that forward caller-supplied data straight into a low-level call, and how a senior auditor catches the bug before it ships. New take every week, and you can follow Jon at @FormallyJon.

A protocol can define exactly which contracts may act on its users’ behalf and still be drained. That permission carries an assumption with it: that the trusted contract will only ever act under the circumstances it was built for. When a single unprotected call lets an attacker drive that contract, the permission still holds and the assumption behind it does not. That is the shape of the bug that drained roughly $3.5 million from Arcadia Finance on Base in July 2025.

TLDR:

  • A privileged rebalance flow passed a caller’s router and calldata into an unchecked call.
  • The call ran as a trusted manager, free to move other users’ assets.
  • A trusted contract was compromised without any key or secret being stolen.
  • A Vanguard custom detector flags the pattern: unvalidated calldata in a low-level call.

When a Privileged Contract Forwards Data It Never Checked

Protocols routinely give one contract power over another. A manager contract acts on behalf of a user, and other contracts authorize it so they can work together. In every case, the contract handing over that power is counting on the other to use it only as intended. Say the contract holding that power takes data from whoever called it and feeds that data to a low-level call. The expectation is then only as safe as the validation in front of it. Without that validation, the contract becomes a way to run arbitrary code under a trusted identity.

So the first move on any low-level call is to identify what an attacker controls. What address is being called, and can they point it anywhere they like? What is in the calldata, and does anything validate it? This vulnerability is common enough to make those questions a habit. In Veridise’s study of its own audit findings, logic and data-validation errors are the two archetypes behind the bulk of high-severity vulnerabilities. An unchecked call like this one is a data-validation failure.

Arcadia: The Call That Came From Inside the Protocol

Arcadia Finance is a DeFi protocol on Base. Users deposit collateral into an account contract and can borrow against that collateral. They can also authorize a manager contract to act on the account’s behalf. The Rebalancer is one such manager. It adjusts a user’s position by swapping tokens through a decentralized exchange, using a router address and swap calldata supplied by whoever initiates the rebalance.

On July 15, 2025, an attacker triggered a rebalance on an account they controlled, using custom calldata that redirected the call to another user’s account rather than the expected DEX contract. This attack drained about $3.5 million in assets from multiple users across several transactions.

The Property the Swap Path Skipped

Start with the rule the code should have enforced. A contract performing an external call for a user needs to establish two things. The target has to be a contract the protocol trusts, and the payload has to do no more than the operation the user asked for. The Rebalancer establishes neither.

Here is the call, in SwapLogic._swapViaRouter():

Both router and data are taken as input from whoever initiated the rebalance. The contract approves the input token to router before calling it. The intent was to invoke a real exchange such as Uniswap, but nothing in the code requires that the target be one.

The _swapViaRouter() function is an internal function though (hence not callable by an attacker directly). The rebalance() function provides a convenient entry point as it will invoke _swapViaRouter() blindly. Spotting the unvalidated call is not typically the hard part. Working out what an attacker can do once they reach it is.

The attack starts innocent enough as the attacker creates an account for themselves with a small LP position and pays the debt of all future victims. While this action may appear selfless in isolation, it was performed so that the victim’s collateral could be withdrawn without violating any health checks. With all victims clear of debt, all collateral is unlocked, allowing the attacker to drain everything.

To perform the attack itself, rebalance is invoked via the Rebalancer on the attacker’s own account. The supplied router and data were crafted such that the exposed low-level call would execute privileged actions within victim’s account rather than an exchange as intended. Since the call was invoked by Rebalancer rather than the attacker, the access control checks pass as Rebalancer was an approved asset manager. This one unprotected low-level call therefore allowed an attacker to smuggle a malicious call into a context assumed to be protected and resulted in the theft of millions of dollars as user assets were withdrawn.

How a Senior Auditor Traces a Low-Level Call

The method that fits this bug is backward taint analysis, and it starts at the call. Low-level calls are a known source of exploits, so a senior auditor stops on every one and works backward from it. Which values decide the target and the payload, and what restrictions are placed on those values?

The technique runs from the sink to the source. router and data decide what the call does, so follow both back through each assignment and each function that passes them along. In the Rebalancer, that chain ends at rebalance(), one call away, where they arrive as arguments from an external caller. Nothing in between restricts either value.

That result is what makes the impact clear. An attacker who reaches the call decides what the Rebalancer invokes and what it sends. The account contract that authorized the Rebalancer places no restriction on what a manager may do, so authority granted for rebalancing covers any action a manager can take. That scope is the real defect the analysis exposes.

From a Manual Review to a Check on Every Commit

The property behind that analysis is a rule about the shape of the code, so it can be written down and run automatically. Doing it by hand does not scale. It has to be repeated for every low-level call the codebase contains, and the one that gets skipped is the one that matters.

Vanguard, the static analyzer Veridise auditors use, encodes that rule as a custom detector. Stated plainly, it flags any low-level call whose calldata comes from an external-function argument with no require validating it. The check is interprocedural. The require can sit in a different function from the call, so the detector follows the data across function boundaries, the way the auditor does.

The guarantee is narrow, and worth stating exactly. The detector reports where the pattern is, not whether a given instance is exploitable. Severity is still the auditor’s judgment. What the detector guarantees is that the pattern stops shipping unnoticed. The companion Pre-Mortem covers what that looks like running on every commit.

What Carries Beyond Arcadia

The point here is bigger than routers and rebalancers. A contract acting for a user should not accept arbitrary data and forward it into a call on trust. It should decode the data, establish that the action is one it is permitted to take, and reject the rest. The same applies in reverse, to the contract granting the privilege. An account that authorizes a manager should limit what that manager is allowed to do, rather than trusting it with every action the account itself can perform.

Look at the privileged functions in your own protocol with that in mind. Access control tells you who may invoke a function. What they can make that function do is a separate question.

Working on a similar protocol?

If your protocol has a privileged contract that forwards caller-supplied calldata into a low-level call, an attacker can use it to perform actions the protocol never intended, such as bypassing access control checks. The attacker does not have to break in. They only need to steer a contract you already trust.

If you want a second pair of eyes before your next deploy, talk to us.

The Takeaway

Low-level call hijacking compromises a trusted contract without stealing a key or a secret. An attacker who controls the target and payload of a privileged contract’s call can point its authority at other users’ assets, which is how roughly $3.5 million left Arcadia’s accounts. Catching this class means checking what restricts the values feeding every low-level call.

More by Veridise

Subscribe to our blog

Be the first to get the latest from Veridise — including educational articles on ZK and smart contracts, audit case studies, and updates on our tool development. Delivered twice a month.

smart contract audit cloud

Subscribe to our newsletter

A monthly round-up for protocol teams: new audit reports, research from our lab, and tooling releases from the Veridise team.

One email a month. Unsubscribe in one click

Contact us for a security audit quote

Secure an earlier audit slot by reaching out early.