Repository navigation
Fix: withdrawEarningsFromSponsorships trusted the return value of arbitrary withdraw() calls - #1092
Conversation
|
Did a bit of archeology. The bug was introduced in this 2023-04-05 commit, older code counts the tokens as in this fix. That commit was way before the 2023-11-03 main audit, so... bad luck I guess. The audit was thorough by that time's standards, i.e. before current LLMs. Might be good to ask LLM to walk through the trust boundary of the Operator: check all incoming data (function arguments, return values) if there are assumptions where it comes from, and are those assumptions checked. Another task could be to just directly ask for "red-teaming" i.e. can one of the participants extract tokens that they don't have the right to; and of course (while more unlikely) if an outsider can extract tokens, like what happened here. The fix itself looks very targeted and clean. Deployment strategy LGTM. |
# Harden Operator↔Sponsorship interactions: `triggerAnotherOperatorWithdraw` double-minting and staking on behalf of others Follow-up to the `withdrawEarningsFromSponsorships` fix (#1092). While auditing all token flows between `Operator` and `Sponsorship` contracts we found two further issues. Neither is as severe as the original drain, but both let an outsider tamper with another party's accounting, so we fix them in the same release. ## 1. `Operator.triggerAnotherOperatorWithdraw` accepts any address as the "other Operator" ### Problem `QueueModule._triggerAnotherOperatorWithdraw(otherOperator, sponsorships)` measures the DATA balance delta around the call `otherOperator.withdrawEarningsFromSponsorships(sponsorships)` and mints the delta to the owner as a fisherman reward (via `_delegate`). It did not check what `otherOperator` actually is: - **Self-call.** Passing the Operator's own address makes it withdraw its own earnings. The earnings are split correctly inside the inner call (profit to the pool, protocol fee, operator's cut), but the outer balance delta then re-mints the whole amount again as a "fisherman reward" to the owner — at the delegators' expense. In our test scenario the owner ends up with 1380 DATA worth of stake instead of 1038, while the delegator drops from 9342 to 9000. - **Arbitrary contract.** The address need not be an Operator at all. A malicious contract can, inside its `withdrawEarningsFromSponsorships`, re-enter the Operator and e.g. delegate DATA to an address of its choosing. That DATA is minted once as a delegation and then minted *again* to the owner as a fisherman reward, as it also shows up in the outer balance delta. With a 10000 DATA attacker deposit the owner's position inflates from 1380 to 10702 DATA, the attacker's 10000 is worth 5093, and the delegator falls from 9000 to 4584. Only the Operator's owner / nodes can call `triggerAnotherOperatorWithdraw`, so this is an insider attack against the pool's delegators rather than an external drain — but it breaks the invariant that owner and delegators share the pool pro rata. ### Fix `QueueModule._triggerAnotherOperatorWithdraw` now rejects: - `otherOperatorAddress == address(this)` → `CannotTriggerOwnWithdraw()` - any address that was not deployed by the `OperatorFactory` (`deploymentTimestamp(addr) == 0`) → `AccessDeniedStreamrOperatorOnly()` Real Operators run the already-fixed `withdrawEarningsFromSponsorships` (balance-delta based, `NotMyStakedSponsorship` check), so a genuine Operator cannot be used to smuggle extra DATA in either. ## 2. Anyone can stake on behalf of any address in `Sponsorship` ### Problem Both staking entry points accepted a beneficiary that was different from the payer: - `Sponsorship.stake(address operator, uint amount)` pulled DATA from `msg.sender` but credited `operator`. - `onTokenTransfer` (ERC677 `transferAndCall`) decoded a 20- or 32-byte address from `data` and credited that address. Hence any third party could, with their own DATA: - **Force an Operator into a Sponsorship it does not service.** The Operator is joined on the Sponsorship side but its own `stakedInto`/`indexOfSponsorships` bookkeeping knows nothing about it. It will not run the stream, so it can be flagged and slashed there, and `withdrawEarningsFromSponsorships` rejects the Sponsorship as `NotMyStakedSponsorship`. - **Inflate an existing stake behind the Operator's back**, increasing its slashing exposure beyond what the Operator ever committed. `Operator.onTokenTransfer` treats DATA coming back from a staked Sponsorship as earnings, so the surprise stake also distorts the pool's value accounting when it's eventually unstaked. (Note the Sponsorship side does not even get a chance to notice: `OperatorContractOnlyJoinPolicy` only checks that the *beneficiary* is an Operator, not who paid.) ### Fix `Sponsorship` now requires the staker to be the beneficiary: - `stake(operator, amount)` reverts with `CannotStakeForOthers(operator, staker)` unless `operator == _msgSender()`. - `onTokenTransfer` reverts with the same error if the decoded address differs from the ERC677 `sender`, in both the 20-byte and 32-byte branches. `Operator`s are unaffected: `StakeModule._stake` already calls `sponsorship.stake(address(this), amount)`. ## Tests - `Operator.test.ts`: *"triggerAnotherOperatorWithdraw does NOT accept the Operator itself or a non-Operator as the other Operator"* — self-call reverts with `CannotTriggerOwnWithdraw`, a fake re-entering contract (`TestFakeOperator`) reverts with `AccessDeniedStreamrOperatorOnly`, and the honest path still yields the expected pro-rata split (owner 1038 / delegator 9342). - `Sponsorship.test.ts`: *"will NOT let stake on behalf of someone else"* — covers `approve` + `stake()`, 20-byte and 32-byte `transferAndCall` data, both for a fresh join and for topping up an existing stake. Verified to fail on the previous `Sponsorship.sol`. - Existing tests that relied on the admin staking for other wallets now have those wallets stake for themselves (`Sponsorship.test.ts`, `MaxOperatorsJoinPolicy.test.ts`, `OperatorContractOnlyJoinPolicy.test.ts`). The *"Multiple stakings and sponsorings"* scenario additionally had its un-awaited `advanceToTimestamp` calls (which raced against the following tx, hence the old "withdraw goes to block +1" comments) replaced by an awaited `evm_setNextBlockTimestamp`, so intermediate earnings expectations are now exact round numbers; final payouts are unchanged. - New test helper contract: `testcontracts/TestFakeOperator.sol`. Full `test/hardhat/OperatorTokenomics` suite: 289 passing. `solhint` and `eslint` clean. ## Deployment Both `Operator` and `Sponsorship` are EIP-1167 clones with no upgrade path, so **existing contracts cannot be patched**; only newly created ones pick up the fixes. | Component | Action | |---|---| | `Operator` template + `NodeModule`, `QueueModule`, `StakeModule` | Redeploy all four and call `OperatorFactory.updateTemplates(...)` (`scripts/upgradeOperatorTemplate.ts`). Only `QueueModule` changed in this PR, but the modules inherit `Operator` for storage layout and are always rolled together. `Operator.version()` stays at 4 (same release as the `withdrawEarnings` fix; the version-history comment lists this fix under 4). | | `Sponsorship` template | Redeploy and call `SponsorshipFactory.updateTemplate(newTemplate)`. | | `OperatorFactory`, `SponsorshipFactory`, `StreamrConfig`, policies | No changes. |
Summary
On 2026-10-09 an attacker drained the free (unstaked) DATA of many Operators by passing a fake "sponsorship" contract to
Operator.withdrawEarningsFromSponsorships. This PR fixes the root cause inStakeModule._withdrawEarnings, adds regression tests that reproduce the attack, and bumpsOperator.version()to 4.Root cause
StakeModule._withdrawEarningsis reachable by anyone and did:There was no check that the address is a Sponsorship this Operator is staked in, and the earnings were taken from the number returned by
withdraw()rather than from DATA actually received.An attacker-controlled contract returning
X = freeBalance / 0.30fromwithdraw()caused:_splitEarningsto send5% × X(protocol fee) out of the Operator's own DATA balanceOperator.withdrawEarningsFromSponsorshipsto fire (X> 5% of Operator value), burning the owner's self-delegation and sending25% × Xto the caller5% + 25% = 30%ofXis the entire free balance. Staked DATA was not reachable since onlytoken.balanceOf(operator)pays out. Once self-delegation was exhausted, the loss fell on delegators.Fix
StakeModule._withdrawEarningsnow has two independent guards:indexOfSponsorships[sponsorship] != 0, otherwise it reverts with the existingNotMyStakedSponsorshiperror.indexOfSponsorshipsis only set in_stake, which already requires the Sponsorship to be deployed bySponsorshipFactory. This alone blocks the exploit.withdraw()'s return value. This matches the pattern already used in_unstake,_forceUnstakeandQueueModule._triggerAnotherOperatorWithdraw, and also closes a latent issue:Sponsorship._withdrawwrapstransferAndCallintry/catch, so even a genuine Sponsorship can return a non-zero payout when no DATA was sent.Operator.version()is bumped from 3 to 4 so patched Operators can be distinguished on-chain (subgraph / UI / scripts).Tests
New tests in
Operator.test.ts→ "Withdrawing and profit sharing":NotMyStakedSponsorship: alone, mixed in with a real staked Sponsorship, and viatriggerAnotherOperatorWithdraw; all balances unchangedNotMyStakedSponsorshipwithdraw()reports a payout that never arrives (newTestAllocationPolicycase 11) →NoEarnings, nothing paid outAll three were verified to fail against the old
StakeModule(the drain succeeded) and pass with the fix. FullOperatorTokenomicssuite passes (287 tests);solhintandeslintare clean.Supporting test contracts:
testcontracts/TestFakeSponsorship.sol(new) — mirrors the attacker's contract:withdraw()returns a settable number, never sends DATAtestcontracts/TestAllocationPolicy.sol— added case 11 (onWithdrawreports more than the Sponsorship holds)Deployment / redeployment
StakeModule(fix) andOperator(version bump only).stakeModuleaddress atinitialize(); there is no setter, andDEFAULT_ADMIN_ROLEis renounced by the factory. Existing Operators remain vulnerable while they hold unstaked DATA and must eventually migrate to new Operator contracts. Until then, owners should stake or withdraw free DATA.OperatorFactorydoes not need redeploying/upgrading. Use the existingscripts/upgradeOperatorTemplate.tsflow: deploy newOperatortemplate,NodeModule,QueueModule,StakeModule, then callOperatorFactory.updateTemplates(...)on every chain where the factory is live.Sponsorship,SponsorshipFactory,StreamrConfig, all policies.