Skip to content

Fix: withdrawEarningsFromSponsorships trusted the return value of arbitrary withdraw() calls - #1092

Merged
hpihkala merged 3 commits into
mainfrom
fix-stakemodule-vulnerability
Oct 10, 2026
Merged

hpihkala merged 3 commits into
mainfrom
fix-stakemodule-vulnerability

Conversation

@hpihkala

@hpihkala hpihkala commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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 in StakeModule._withdrawEarnings, adds regression tests that reproduce the attack, and bumps Operator.version() to 4.

Root cause

StakeModule._withdrawEarnings is reachable by anyone and did:

sumEarnings += sponsorshipAddresses[i].withdraw(); // never checked

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.30 from withdraw() caused:

  • _splitEarnings to send 5% × X (protocol fee) out of the Operator's own DATA balance
  • the fisherman branch in Operator.withdrawEarningsFromSponsorships to fire (X > 5% of Operator value), burning the owner's self-delegation and sending 25% × X to the caller

5% + 25% = 30% of X is the entire free balance. Staked DATA was not reachable since only token.balanceOf(operator) pays out. Once self-delegation was exhausted, the loss fell on delegators.

Fix

StakeModule._withdrawEarnings now has two independent guards:

  1. Only staked Sponsorships are accepted. Every address must satisfy indexOfSponsorships[sponsorship] != 0, otherwise it reverts with the existing NotMyStakedSponsorship error. indexOfSponsorships is only set in _stake, which already requires the Sponsorship to be deployed by SponsorshipFactory. This alone blocks the exploit.
  2. Earnings are measured from the DATA balance change, not from withdraw()'s return value. This matches the pattern already used in _unstake, _forceUnstake and QueueModule._triggerAnotherOperatorWithdraw, and also closes a latent issue: Sponsorship._withdraw wraps transferAndCall in try/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":

  • fake sponsorship → NotMyStakedSponsorship: alone, mixed in with a real staked Sponsorship, and via triggerAnotherOperatorWithdraw; all balances unchanged
  • real factory-deployed Sponsorship the Operator is not staked in → NotMyStakedSponsorship
  • staked Sponsorship whose withdraw() reports a payout that never arrives (new TestAllocationPolicy case 11) → NoEarnings, nothing paid out

All three were verified to fail against the old StakeModule (the drain succeeded) and pass with the fix. Full OperatorTokenomics suite passes (287 tests); solhint and eslint are clean.

Supporting test contracts:

  • testcontracts/TestFakeSponsorship.sol (new) — mirrors the attacker's contract: withdraw() returns a settable number, never sends DATA
  • testcontracts/TestAllocationPolicy.sol — added case 11 (onWithdraw reports more than the Sponsorship holds)

Deployment / redeployment

  • Changed production bytecode: StakeModule (fix) and Operator (version bump only).
  • Existing Operators cannot be patched. They are EIP-1167 clones that store their stakeModule address at initialize(); there is no setter, and DEFAULT_ADMIN_ROLE is 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.
  • OperatorFactory does not need redeploying/upgrading. Use the existing scripts/upgradeOperatorTemplate.ts flow: deploy new Operator template, NodeModule, QueueModule, StakeModule, then call OperatorFactory.updateTemplates(...) on every chain where the factory is live.
  • Not affected: Sponsorship, SponsorshipFactory, StreamrConfig, all policies.

@hpihkala
hpihkala requested a review from jtakalai October 9, 2026 21:24
@jtakalai

Copy link
Copy Markdown

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.

@hpihkala
hpihkala added this pull request to stack #1095 October 10, 2026 10:09
@hpihkala

Copy link
Copy Markdown
Contributor Author

Thanks for the review @jtakalai!

Might be good to ask LLM to walk through the trust boundary of the Operator

That's pretty much what I did, and it came up with the findings and fixes in #1093

@hpihkala
hpihkala merged commit 571757a into main Oct 10, 2026
2 checks passed
@hpihkala
hpihkala deleted the fix-stakemodule-vulnerability branch October 10, 2026 16:15
hpihkala added a commit that referenced this pull request Oct 10, 2026
# 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. |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants