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.
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.
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.
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 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.
There are a few sound options, roughly from best to worst.
sources[i] to be on it.token.balanceOf(address(this)) before the loop and after it. Credit the difference, never the reported number.When we review accounting logic, these are the questions we ask first:
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.
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.
A wrapped recovery deadline and a replayable signed payload cost an Argent-style smart wallet system about $59.8K on Ethereum. How it worked and how to fix it.
A recovery bot finalized stale Sui Bridge withdrawals and paid 86 ETH, 109K USDT and 0.9 WBTC to the Cetus exploiter. What went wrong and what to fix.
How a permissionless accept() and a buyback with no slippage limit let an attacker drain 2.31 ETH from the KolanHook Uniswap v4 hook on Ethereum.
UNCX's token vesting contract lost roughly $87.7K when an attacker exploited a share-accounting flaw in release(), topUpVesting(), and cancel(). We break down the root cause, the exact attack sequence, and what builders should take away from it — without the jargon.