Trusting an External Contract's Return Value: The Smart Contract Bug That Keeps Draining Vaults | PandaSecurity
Web3 Security

Trusting an External Contract's Return Value: The Smart Contract Bug That Keeps Draining Vaults

PandaSecurity Team •
#Smart Contract Vulnerability#Reentrancy and External Calls#Bug Bounty
Abstract illustration of a vault being drained through an untrusted external call

Some bugs are clever. This one is not.

While reviewing an unverified vault contract recently, we ran into a pattern that is almost embarrassing to write about in 2026: a function takes a list of addresses from the caller, calls each one, and credits an internal balance based on whatever number comes back. No whitelist. No balance check before and after. Just trust.

We are not naming the protocol. Until it is fixed, the details stay with the team and the bounty platform. But the pattern is common enough that it deserves a proper write-up, because we keep seeing it in new code, including code written after every major audit report has warned about it.

The pattern in one paragraph

A function accepts an array of addresses chosen by the caller. For each address it makes an external call and reads two integers back, say base and locked. Those numbers are added to a user’s internal balance. A flag then lets the caller withdraw right away.

The attacker deploys a contract that returns type(uint128).max. They call the function with their own address in the array, set the withdraw flag, and walk away with real tokens that were never deposited.

That is the whole exploit. The accounting layer treated attacker-controlled data as a trusted source.

A simplified version of the vulnerable code

This is a sanitized sketch, not the original code:

function sweep(
    address[] calldata sources,
    address token,
    address to,
    bool withdrawAfter
) external nonReentrant {
    uint256 totalBase;
    uint256 totalLocked;

    for (uint256 i = 0; i < sources.length; i++) {
        // `sources[i]` is chosen by the caller. Whatever it returns is booked.
        (uint256 base, uint256 locked) = IDelegation(sources[i]).pull(token, to);
        totalBase += base;
        totalLocked += locked;
    }

    balances[to][token].base   += totalBase;
    balances[to][token].locked += totalLocked;

    if (withdrawAfter) {
        _withdraw(to, token);
    }
}

Nothing in this function verifies that tokens actually moved. The vault never compares its own balance before and after the loop. It never checks that sources[i] is a contract the protocol deployed. A single line of defensive code would have killed the bug.

Why this keeps happening

We think there are three reasons, and none of them are “developers are careless.”

1. The interface looks internal. When a team writes IDelegation(x).pull(...), they picture their own delegation contract on the other end. The type system says nothing about who x really is, but the code reads as if it were a trusted module. The mental model and the actual trust boundary drift apart.

2. Batch functions grow in the dark. Single-item functions usually get reviewed carefully. The batch version is added later for gas efficiency, copies the loop pattern, and quietly drops a check or two. In our experience, batch and sweep helpers are where a large share of accounting bugs live.

3. Reentrancy guards create false comfort. The function above has nonReentrant, and it feels safe. But a reentrancy guard only stops reentry into guarded functions. It does nothing about a malicious return value, and it does not protect sibling functions that lack the modifier. A guard is not an input validation strategy.

The unverified-source angle

The unusual part of this finding was not the bug. It was the setting: the contract had no verified source, and we were reading decompiled output.

Our view, and we will say it plainly: unverified source is not a security measure. It slows down a casual reader by a few minutes. It does not slow down a bounty hunter or an attacker with bytecode tooling, and today it barely slows down an AI-assisted workflow either. In this case a decompiled listing made the external-call-then-credit flow obvious almost immediately.

Decompilers get names and types wrong, and parts of the output were plainly mis-reconstructed. That is exactly why we never report a finding straight from decompiled code. The method is: spot the suspicious flow, confirm it against the bytecode and the real storage layout, then prove it with a working PoC on a fork. A suspicion is not a vulnerability until it drains something on a fork.

If your protocol’s only protection is that nobody can read your Solidity, you have a disclosure date, not a security model.

How to fix it

There are a few sound options, roughly from best to worst.

The PandaSecurity audit checklist for external calls

When we review accounting logic, these are the questions we ask first:

  1. Who chooses the address being called: the protocol or the caller?
  2. Is any number returned by an external call written into storage?
  3. Does the contract verify that value actually arrived, using its own balance before and after?
  4. Can a deposit credit be withdrawn in the same transaction?
  5. Do batch and sweep variants carry the same checks as the single-item versions?
  6. What can the call do with the control flow it receives, and which unguarded functions can it reach?

If any of these has an uncomfortable answer, we keep digging. Most of the critical findings we have submitted over the last few years started with exactly this checklist and about ten minutes of reading.

Takeaways

At PandaSecurity we audit smart contracts and chain-level code, and we spend a lot of time on exactly this kind of bug, the simple ones that still slip through. If you are shipping a vault, a router, or any contract that moves other people’s money, the PandaSecurity Team is happy to take a look before an attacker does.

Responsible disclosure note: details of the affected protocol are withheld until a fix is deployed.

Related articles

← Back to Blog