Pennysia Exploit: Arbitrary Call Drains Old Approvals | PandaSecurity
Web3 Security

Pennysia Exploit: Arbitrary Call Drains Old Approvals

PandaSecurity Team •
#Web3 Security#DeFi Exploit#Token Approvals#Smart Contract Audit
Diagram of the Pennysia Settlement exploit: a swap route whose venue call pulls tokens from a victim wallet using an old approval

On 12 November 2026 at 13:59 UTC, a single Ethereum transaction pulled tokens out of four user wallets through the PennysiaSettlement contract, the swap settlement layer behind the Pennysia web app. Nobody in those wallets signed anything that day. The attacker used approvals the users had granted to the Settlement contract earlier and never revoked.

SlowMist flagged the incident on 13 November after users reported stolen funds, describing it as an arbitrary external call in executeSwap that let the attacker spend leftover approvals. SlowMist’s figure for the loss is about $5,131 across four users. The amount is small, but the pattern is one of the most repeatable in DeFi: if a contract that holds user allowances can be told to call any address with any calldata, every one of those allowances belongs to whoever finds the bug.

What Happened

The attack transaction is 0x2716c50e…5b732d, included in block 26,399,314. It was sent by the externally owned account 0x813a5ab666adeba907548235fbac087642878300 to a helper contract at 0x96bb89e3f86312968ce11a1bd8d9d28f4891a17a. The vulnerable contract is PennysiaSettlement at 0x3aad97e5a91b8e43b7dc830aceb004307678795e, which is verified on Blockscout.

We pulled the transaction receipt and a call trace from public Ethereum RPC endpoints. The token transfers in the receipt match what SlowMist and other trackers reported:

AssetAmount takenSource wallet
USDC5,0000xf2ff…6998
USDC6.1334890x6108…f76a
wstETH0.050x56eb…7000
YUNO101,0000x40a8…9497

Each asset followed the same path: victim wallet → Settlement contract → attacker helper contract → attacker EOA. The helper repeated one recipe four times inside the same transaction, once per victim.

Timeline

How the Settlement Contract Works

executeSwap is designed to take a list of “soft routes” and try them in order until one succeeds. For a SYNC route the caller supplies a Route struct that includes:

The contract first pulls amountIn of sellToken from the caller, approves the route’s spender, then makes a low-level call: venue.call{value: venueValue}(step.callData). Afterwards it performs two balance checks. The amount of spendAs held by the contract must have gone down (something was “spent”), and the amount of buyToken must have gone up. Whatever increase is measured is paid to the caller as their swap output.

The design leaves the choice of venue and calldata entirely to the caller. The web app is expected to build sensible routes, and the source even carries a comment saying direct interaction is “not recommended”. Comments do not restrict callers, though.

The Root Cause: The Contract Will Call Anything

The only restrictions on the venue are that it is not the zero address and not the Settlement contract itself. There is no allowlist of DEX routers, and nothing stops callData from being an ERC-20 transferFrom. Because the call is made by the Settlement contract, any token allowance that a user ever granted to that contract can be spent by the call.

Decoding the attacker’s first executeSwap call from the trace shows exactly how it was used:

The venue call moved the victim’s USDC into the Settlement contract using the victim’s old approval. The “buy-side” check then saw a USDC increase of exactly 5,000 and treated it as the swap’s output, paying it to the caller.

The “sell-side” check should have been the speed bump: no real swap happened, so the contract’s balance of spendAs should not have changed. But spendAs was the attacker’s own token, and the attacker controls what its balanceOf returns. In the trace, every balanceOf query on that token makes a nested call to the victim token’s balance, which suggests it was written to report a lower balance once the stolen tokens arrived. Either way, a balance check against a token the caller chooses cannot prove anything about what the venue actually did.

A simplified illustration

The snippet below is a simplified sketch of the vulnerable flow, not the deployed code:

// Simplified for illustration
function executeSyncAttempt(Route calldata r, address buyToken, uint256 buyBefore, address caller) external {
    require(r.venue != address(0) && r.venue != address(this));

    uint256 spendBefore = IERC20(r.spendAs).balanceOf(address(this)); // caller-chosen token
    (bool ok,) = r.venue.call(r.callData);                             // caller-chosen target + calldata
    require(ok);

    uint256 spendAfter = IERC20(r.spendAs).balanceOf(address(this));
    require(spendBefore > spendAfter);                                  // can be faked

    uint256 netBuy = IERC20(buyToken).balanceOf(address(this)) - buyBefore;
    IERC20(buyToken).transfer(caller, netBuy);                          // pays out someone else's tokens
}

If r.venue is a token and r.callData is transferFrom(victim, address(this), amount), the function becomes a public “withdraw from anyone who approved me” endpoint.

Why Leftover Approvals Make This Worse

Most of the money at risk was never deposited in Pennysia. It sat in user wallets. The users had simply approved the Settlement contract at some point, and many wallets and dapps still default to large or unlimited approvals so that later swaps need one fewer signature.

This is the same class of risk we covered in the Avalanche Safe stale approval exploit, where unlimited approvals to an address that later received attacker code turned into a $7.45M loss. It also echoes the KolanHook Uniswap v4 exploit: when a contract can be steered by any caller, the value it can touch, not the value it holds, sets the blast radius.

The attacker’s helper also read each victim’s allowance and balance before every drain, then took exactly the amount available, so it only needed a list of addresses with open approvals.

Lessons for Builders

Never give callers a free-form call from a contract that holds allowances. Aggregators and settlement contracts that receive user approvals should separate the two roles. A common pattern is to pull user funds into the settlement contract and make external venue calls from a separate executor contract that holds no approvals at all.

Allowlist venues and forbid token selectors. If arbitrary calldata is a product requirement, restrict venue to a governed list of routers, and reject calldata whose selector is transferFrom, permit, Permit2 transfers or any other function that moves third-party funds. Rejecting the case where venue is a token is a cheap extra guard.

Do not trust balances of caller-supplied tokens. A spend check only means something when the token is known and honest. Measure real input with known tokens, or account for input explicitly instead of inferring it from balanceOf deltas.

Bind output to input. A swap that turns 1 wei of a freshly deployed token into 5,000 USDC should fail any sanity check. Price bounds against an oracle, or simply requiring the input token to be on an allowlist, would have made the route fail.

Fuzz with hostile routes. Invariant tests should assert that no call to executeSwap can reduce the balance or allowance of any address other than msg.sender. That one property captures this entire bug class.

Keep the kill switch reachable. Pennysia’s swapEnabled flag let the team close the entry point. Pair a pause with monitoring for transferFrom calls where from is not the transaction’s caller, which is exactly the signal this attack produced.

What Users Should Know

If you have ever approved 0x3aad97e5a91b8e43b7dc830aceb004307678795e for any token, revoke that approval now using a revoke tool or your wallet’s approvals page, even though swaps are currently disabled. Revoking stops future use of an allowance; it cannot bring back tokens that have already moved. More generally, prefer exact-amount approvals or Permit2 signatures with short expiry for swap apps, and review old approvals regularly. This article is technical commentary, not financial advice.

Sources

Closing Thoughts

The Pennysia loss was small, and the team’s kill switch now blocks the path, but the bug is worth studying because it is so easy to write. A flexible “call this venue with these bytes” route looks harmless in a swap contract until you remember that the contract is also the spender on every approval its users ever gave. If you are building an aggregator, intent settler or any contract that receives token approvals, talk to our team or check our audit pricing to have your external-call surface reviewed before someone else maps it for you.

Related articles

← Back to Blog
Need an audit? Contact us