Seneca Chamber performOperations() Arbitrary-Call Approval Drain (2024-02-28)

  • Name: Seneca Chamber performOperations() arbitrary-call approval drain
  • Date: 2024-02-28
  • Protocol: Seneca Protocol
  • Chain: Ethereum + Arbitrum
  • Category: arbitrary call token-approval drain / router approval abuse
  • Loss: about $6.4M stolen from user wallets; roughly 80% later returned after an onchain whitehat request, with about 20% apparently kept as bounty
  • Summary: Seneca’s per-collateral Chamber contracts combined two dangerous properties: they were already approved ERC-20 spenders for user collateral, and they exposed a generic execution path through performOperations(). Attackers discovered that action code 30 reached _call(), which decoded attacker-supplied (callee, callData, ...) and then executed callee.call(callData) with only a blacklist check. That let the attacker make a live Chamber invoke collateral-token transferFrom(victim, attacker, amount). The protocol’s TVL was not the main source of loss; undeployed tokens still sitting in user wallets were.
  • Trigger / entry point:
    • Seneca users approved specific Chamber contracts to spend collateral tokens needed for borrowing against LST and Pendle PT positions.
    • The relevant external entrypoint was Chamber.performOperations(uint8[] actions, uint256[] values, bytes[] datas).
    • Public analysis showed that actions[i] == 30 selected Constants.OPERATION_CALL, which dispatched into internal helper _call(...).
    • _call(...) decoded attacker-controlled call data into (address callee, bytes callData, bool useValue1, bool useValue2, uint8 returnValues) and then executed callee.call{value:value}(callData).
  • Root cause:
    • Seneca gave already-approved spender contracts a generic arbitrary external-call surface.
    • The decisive invariant should have been a Chamber may only execute tightly validated protocol-internal operations for the caller's own debt position, not arbitrary low-level calls against arbitrary token contracts.
    • Instead, _call() only checked that the target address was not blacklisted; it did not enforce a strict allowlist of callable contracts or selectors, and it did not bind execution to a quoted or protocol-owned action.
    • Because the Chamber itself was the approved spender, token contracts correctly honored transferFrom() when the attacker made the Chamber call them.
  • Exploit path:
    1. The attacker enumerated wallets on Ethereum and Arbitrum that had previously granted large or infinite approvals to Seneca Chamber contracts.
    2. For each target collateral, the attacker chose a live Chamber that still held spender authority over user wallets.
    3. They called performOperations() with a one-element actions array containing operation code 30, causing the Chamber to route into _call().
    4. Inside datas[0], they encoded the collateral token address as callee and encoded ERC-20 transferFrom(victim, attacker, amount) as callData.
    5. The Chamber executed that low-level call as msg.sender, so the token contract saw an approved spender moving funds from the victim wallet to the attacker.
    6. The attacker repeated the pattern across many wallets and collateral types, draining undeployed user assets rather than already-deposited collateral.
    7. Seneca later blacklisted collateral-token addresses from Chamber _call() targets and negotiated an 80% return through an onchain whitehat request.
  • What the attacker needed to be true:
    • Victim wallets needed existing ERC-20 approvals to the relevant Chamber contract.
    • performOperations() needed to leave OPERATION_CALL reachable to arbitrary callers.
    • _call() needed to accept attacker-chosen target address and calldata without binding them to a safe protocol action.
    • The chosen token contracts needed to implement standard allowance semantics so Chamber-originated transferFrom() would succeed.
  • Technical anatomy worth remembering:
    • This was the cleanest version of the router-approval-drain class: the attacker did not need spoofed callbacks, malicious adapters, or proxy upgrade tricks. A raw generic call on an already-approved spender was enough.
    • The vulnerable primitive lived in Chamber, but the economic blast radius lived in user wallets. That distinction matters because protocol TVL can look intact while user balances disappear.
    • Seneca’s own postmortem noted that direct protocol deposits were not the primary loss surface; the exploit mainly targeted wallet balances that remained approved but undeployed.
    • Public discussion also noted that Seneca’s pause controls were not externally callable, so the team leaned on Chamber blacklisting and user approval revocations rather than an immediate clean pause.
    • In the exploit-pattern taxonomy, Seneca is the crisp spender-local generic execution surface subvariant: the approved contract itself exposed a near-raw call language, so the attacker did not need any offchain route-policy corruption or middleware-bypass trick to monetize standing approvals.
    • The exploit preserved the recurring lesson that arbitrary call + pre-existing approval is already a wallet drainer, even when the protocol thinks of the call surface as an internal flexibility feature.
  • Representative transactions and addresses:
    • Example Ethereum Chamber used in public analysis: 0x65c210c59B43EB68112b7a4f75C8393C36491F06 (PT-rsETH Chamber)
    • Example Ethereum Chamber used in public alerts: 0xBC83F2711D0749D7454e4A9D53d8594DF0377c05 (PT-weETH Chamber)
    • Example exploit transaction highlighted by Rekt: 0x9f3712672be7a120757d42fbe15ceefe9578914946bacbd0f3531e7fb7305576
    • Example exploit transaction highlighted by Cyfrin analysis: 0x23fcf9d4517f7cc39815b09b0a80c023ab2c8196c826c93b4100f2e26b701286
    • Seneca onchain whitehat request transaction: 0x6e81e21ae6345279060de5c2c27378e2dca4c27a83615c8d835d218c4f66ffbd
    • Main exploiter address reported in public coverage: 0x94641c01a4937f2c8ef930580cf396142a2942dc
    • Recovery multisig published by Seneca: 0xb7aF0Aa318706D94469d8d851015F9Aa12D9c53a
  • Response / mitigation:
    • Seneca publicly warned users to revoke approvals on both Ethereum and Arbitrum.
    • The team blacklisted collateral-token targets in Chamber contracts so _call() could no longer directly invoke those token transferFrom() paths.
    • Seneca negotiated publicly with the attacker through an onchain message, offering a 20% bounty for the return of the remaining funds.
    • Roughly 1,537 ETH was returned to the designated recovery safe, with about 300 ETH reportedly kept by the attacker across new addresses.
    • The postmortem acknowledged that the underlying lack of input validation in _call() still existed, but argued the blacklist was enough to let users unwind and repay safely.

Pattern tags

  • arbitrary-call-token-approval-drain
  • Approved spender exposed its own generic call language: performOperations() kept OPERATION_CALL reachable to arbitrary callers, so the Chamber itself became the arbitrary-call surface instead of merely interpreting one attacker-shaped route or quote
  • Wallet approvals, not escrowed TVL, were the balance sheet: the attacker harvested undeployed user collateral by making Chambers call token contracts directly with transferFrom(victim, attacker, amount) calldata
  • Blacklist-first execution policy failed at the approval boundary: _call() only rejected known bad targets rather than proving one allowlisted callee / selector family still belonged to a legitimate debt-management action
  • Position-management meaning and downstream call meaning came apart: user has a Chamber, Chamber is executing an operation, and this exact token call belongs to that operation were treated as if they were the same fact

Why the exploit worked

  • Seneca’s architecture already put each Chamber inside users’ allowance tables, which meant the moment performOperations() exposed one low-level call branch, the Chamber had to be reviewed like a wallet-spending router rather than like an internal position helper. The dangerous primitive was not obscure accounting drift; it was an approved spender speaking attacker-authored calldata.
  • OPERATION_CALL collapsed the middle authorization layer Seneca needed. The protocol had position context, caller context, and operation codes, but none of those facts were turned into one narrower object proving this exact Chamber action may talk to this exact callee / selector family for this exact debt-management meaning before _call() executed.
  • The exploit did not require spoofed callbacks, malicious offchain route metadata, or an upgrade-controlled adapter. That is what makes Seneca such a clean family exemplar: once the already-approved Chamber itself exposed a near-raw call language, token contracts correctly honored transferFrom() because from their perspective the approved spender was behaving exactly as authorized.
  • Seneca’s own postmortem reinforces the real balance-sheet lesson: funds already deposited into the protocol were not the main loss surface. The live solvency object was user wallet + standing Chamber approval, and the protocol had no stronger boundary than a blacklist to stop that wallet authority from being repurposed.
  • The post-exploit blacklist partially contained the specific path, but it also clarified the root miss. If blacklisting collateral-token targets is enough to stop the drain while leaving the broader input-validation gap in place, then the protocol never had a positive proof that its call surface was debt-management-only in the first place.

Detection and audit pivots

  • Enumerate every opcode or action branch reachable from performOperations()-style dispatchers on contracts users approve as spenders. The highest-signal question is not does the protocol expose multicall-like ergonomics? but can one branch make an approved spender issue an arbitrary low-level call?
  • Add negative tests for the exact Seneca failure shape: approved Chamber, public operation array, callee = token, callData = transferFrom(victim, attacker, amount). If that payload can ever succeed, the Chamber is already a wallet drainer.
  • Review blacklist-based defenses as admissions of incomplete authorization, not as primary safety models. If the protocol story is still this low-level call helper is safe because we block known bad targets, ask what proves the next token, wrapper, proxy, or helper target is impossible rather than merely not yet listed.
  • Inventory undeployed-wallet exposure separately from protocol TVL. Seneca-style drains often leave vault balances and user debt accounting mostly intact while the economically live risk sits in historical approvals on wallets that dashboards may not emphasize.
  • Check incident-containment mechanics on approval surfaces, not just core lending functions. Public discussion around Seneca highlighted that pauseability itself was not a clean first responder, which made blacklist edits and revoke campaigns do more of the emergency work than a narrow kill switch should have.

Why this incident matters

  • It is a highly legible archetype of the arbitrary-call approval-drain pattern, because the exploit path is almost embarrassingly direct: performOperations() → _call() → token transferFrom().
  • It shows why battle-tested fork rhetoric and conventional audits do not neutralize architectural risk when a protocol combines generic call flexibility with persistent wallet approvals.
  • The preserved healthy-side boundary is sharper than blacklist bad callees: Seneca needed one operation-local approval object that kept caller position scope, allowed callee / selector family, token-spend semantics, and recipient meaning fused before any already-approved Chamber was allowed to issue a low-level call.
  • It is also a useful contrast to ParaSwap and Sushi RouteProcessor2: Seneca did not need a spoofed callback or fake pool. The unsafe primitive was simply left open on the spender itself.
  • It is equally a useful contrast to Revert-style route-metadata abuse: Seneca did not become dangerous because imported quote fields redefined policy; it was dangerous because the already-approved spender locally exposed the wrong primitive.

Failure-prevention checklist

  • Remove or heavily constrain generic external-call opcodes on any contract users approve as an ERC-20 spender. A Chamber that can pull wallet collateral should not also expose a near-raw callee.call(callData) language to arbitrary callers.
  • Bind every operation code to one narrow protocol meaning: exact caller scope, exact position or vault context, exact allowed callee set, exact selector family, exact recipient semantics, and explicit value-flow invariants. Action enum exists is not enough if one enum branch still means arbitrary external call.
  • Route all approval-consuming actions through one shared validation funnel. If a Chamber may still touch wallet balances, its operation dispatcher should prove this action is an authenticated debt-management step before any low-level call becomes reachable.
  • Deny token-contract calls by default from generic execution helpers. If a helper can reach ERC-20 transferFrom(), approve(), or permit-style entrypoints with attacker-shaped arguments, treat it as wallet-drain critical immediately.
  • Prefer exact-amount or short-lived allowance patterns where possible. Persistent collateral approvals to per-market Chambers dramatically widened the blast radius once _call() stayed reachable.
  • Keep a real emergency containment path for spender surfaces: pause, kill-switch, selector disablement, or other controls that can shut off approval exercise faster than a userwide revoke campaign.
  • Model undeployed wallet balances as in-scope assets during review. The danger is not just what sits in protocol TVL; it is what an already-approved Chamber can still pull from user wallets after the user leaves.
  • Fuzz operation arrays adversarially. Tests should explicitly try performOperations() payloads that target token contracts, helpers, wrappers, and internal utility addresses rather than only honest debt-management sequences.

Audit prompts

  • Which externally reachable operation codes can cause an already-approved Chamber to issue a raw external call, and what exact proof keeps those branches tied to one legitimate debt-management action?
  • Can any public operation path make the Chamber call a collateral token contract directly, or call a helper that will eventually invoke transferFrom() with attacker-chosen from / to / amount values?
  • Is the policy model allowlist-first or blacklist-first? If the defense story is still we block known bad callees, what stops the next equally dangerous token, proxy, wrapper, or helper target?
  • What exact object binds caller identity, Chamber identity, position scope, callee, selector, and recipient semantics before wallet allowance can be exercised?
  • Are generic low-level call helpers reachable only through protocol-owned workflows, or can arbitrary users package attacker-shaped calldata into one of the standard operation arrays?
  • If a Chamber is paused or partially blacklisted, which dangerous selectors or target families remain reachable anyway through alternate operation codes?
  • Which approvals remain live after a user repays, migrates, or stops interacting, and how quickly can operators enumerate those spender relationships during an incident?
  • If this exploit started again today, could the team disable the approval-exercising path immediately, or would containment still depend mainly on users revoking allowances wallet by wallet?

Lessons that should already be standard

  • Already-approved protocol contracts are custody surfaces even if the protocol describes itself as non-custodial. If the contract can still pull wallet assets, it already sits inside the user’s balance-sheet perimeter.
  • Generic operation framework is often just polite language for embedded scripting engine. If one opcode still means arbitrary external call, the architecture needs scrutiny at the same level as a router or multicall engine.
  • Blacklists are not a primary safety model for spender contracts. They can narrow one known exploit path, but they do not create a positive proof that each remaining call is legitimate.
  • TVL is not the full loss surface. Approval drains often leave protocol accounting mostly intact while destroying undeployed user balances that dashboards barely count.
  • Fork lineage and prior audits do not rescue a design that combines broad wallet approvals with an underconstrained call surface. The dangerous composition can stay latent until one reachable branch exposes it cleanly.
  • Incident response for approval drains has to be designed before launch. If a spender contract cannot be cleanly paused, the fallback is a revoke race against attackers, which is a weak place to discover your operational model.
  • The clean design question is blunt: what exact debt-management action is this Chamber allowed to perform right now, and why is any other external call shape still reachable while it holds wallet allowance?

Internal linkages

  • Core recurring pattern: arbitrary-call-token-approval-drain.
  • Positive-design companion: approval-funded-route-authority-truth-security-object — useful when the sharper follow-up is what exact operation-local authority object should have bound Chamber identity, caller / position scope, allowed callee+selector family, and recipient meaning before wallet allowance could be exercised.
  • Broader spender-boundary companion: spender-authority-truth-security-object — the right follow-up when the main question is why any already-approved Chamber was allowed to speak in an arbitrary low-level call language at all.
  • Adjacent pattern: callback-origin-authentication-failure — ParaSwap and Sushi-like approval drains depended on spoofed settlement context, while Seneca was the more direct generic spender call variant.
  • Adjacent pattern: privileged-message-dispatcher-abuse — both classes revolve around overpowered generic execution surfaces, but Seneca monetized user approvals rather than protocol-owned admin authority.

Control surface