From a8f96990872b6ea2fb8cfa8a7551feaa7ee0e393 Mon Sep 17 00:00:00 2001 From: Henri Pihkala Date: Fri, 9 Oct 2026 20:25:19 +0200 Subject: [PATCH 1/3] fix vulnerability --- .../contracts/OperatorTokenomics/Operator.sol | 3 +- .../OperatorPolicies/StakeModule.sol | 18 +++- .../testcontracts/TestAllocationPolicy.sol | 14 ++- .../testcontracts/TestFakeSponsorship.sol | 21 +++++ .../OperatorTokenomics/Operator.test.ts | 89 ++++++++++++++++++- 5 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestFakeSponsorship.sol diff --git a/packages/network-contracts/contracts/OperatorTokenomics/Operator.sol b/packages/network-contracts/contracts/OperatorTokenomics/Operator.sol index 61670a2f4..1e0ab10d4 100644 --- a/packages/network-contracts/contracts/OperatorTokenomics/Operator.sol +++ b/packages/network-contracts/contracts/OperatorTokenomics/Operator.sol @@ -781,8 +781,9 @@ contract Operator is Initializable, ERC2771ContextUpgradeable, IERC677Receiver, * 1: latestDelegationTimestamp[delegator] added in 2024-01-24 (ETH-717) * 2: recompilation and redeployment (ETH-748) * 3: slash self-delegation when getting kicked (ETH-754) + * 4: withdrawEarningsFromSponsorships only accepts staked Sponsorships and measures earnings from the DATA balance change (2026-10-09 Operator drain) */ function version() public pure returns (uint) { - return 3; + return 4; } } diff --git a/packages/network-contracts/contracts/OperatorTokenomics/OperatorPolicies/StakeModule.sol b/packages/network-contracts/contracts/OperatorTokenomics/OperatorPolicies/StakeModule.sol index f9d85343d..fd66d11a9 100644 --- a/packages/network-contracts/contracts/OperatorTokenomics/OperatorPolicies/StakeModule.sol +++ b/packages/network-contracts/contracts/OperatorTokenomics/OperatorPolicies/StakeModule.sol @@ -104,11 +104,25 @@ contract StakeModule is IStakeModule, Operator { emit OperatorValueUpdate(totalStakedIntoSponsorshipsWei - totalSlashedInSponsorshipsWei, token.balanceOf(address(this))); } - /** @dev this is in stakeModule because it calls _splitEarnings */ + /** + * Withdraw earnings from the given Sponsorships and split them (protocol fee, operator's cut, delegators' profit). + * Only Sponsorships this Operator is currently staked in are accepted, and the earnings are measured as the actual change + * in this contract's DATA balance, NOT as the number returned by `withdraw()`. + * @dev This function is callable by anyone (via withdrawEarningsFromSponsorships), so the input must NOT be trusted: + * @dev an attacker-controlled contract could return arbitrary "earnings" from withdraw() without sending any DATA, + * @dev and the fisherman reward + protocol fee of those fake earnings would then be paid out of this contract's own DATA balance. + * @dev this is in stakeModule because it calls _splitEarnings + */ function _withdrawEarnings(Sponsorship[] memory sponsorshipAddresses) public returns (uint sumEarnings) { + uint balanceBeforeWei = token.balanceOf(address(this)); for (uint i; i < sponsorshipAddresses.length; i++) { - sumEarnings += sponsorshipAddresses[i].withdraw(); // this contract receives DATA tokens + Sponsorship sponsorship = sponsorshipAddresses[i]; + if (indexOfSponsorships[sponsorship] == 0) { + revert NotMyStakedSponsorship(); + } + sponsorship.withdraw(); // this contract receives DATA tokens } + sumEarnings = token.balanceOf(address(this)) - balanceBeforeWei; if (sumEarnings == 0) { revert NoEarnings(); } diff --git a/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestAllocationPolicy.sol b/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestAllocationPolicy.sol index d5bbe372a..98e56cdaf 100644 --- a/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestAllocationPolicy.sol +++ b/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestAllocationPolicy.sol @@ -15,6 +15,7 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship { bool failEmptyOnIncrease; bool sendDataWithFailInGetInsolvencyTimestamp; bool failInGetInsolvencyTimestamp; + bool reportFakePayoutOnWithdraw; } function localData() internal view returns(LocalStorage storage data) { @@ -22,6 +23,7 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship { assembly {data.slot := storagePosition} // solhint-disable-line no-inline-assembly } + // solhint-disable-next-line code-complexity function setParam(uint testCase) external { if (testCase == 1) { require(false, "test_setParam"); @@ -43,6 +45,8 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship { localData().sendDataWithFailInGetInsolvencyTimestamp = true; } else if (testCase == 10) { localData().failInGetInsolvencyTimestamp = true; + } else if (testCase == 11) { + localData().reportFakePayoutOnWithdraw = true; } } @@ -86,7 +90,15 @@ contract TestAllocationPolicy is IAllocationPolicy, Sponsorship { } } - function onWithdraw(address) external pure returns (uint payoutWei) { + /** + * Test case 11: report a payout that is larger than the Sponsorship's DATA balance. + * Sponsorship._withdraw then does `try token.transferAndCall(...) {} catch {}`, so the transfer fails silently + * and withdraw() returns a number even though NO DATA was actually sent to the Operator. + */ + function onWithdraw(address) external view returns (uint payoutWei) { + if (localData().reportFakePayoutOnWithdraw) { + return token.balanceOf(address(this)) + 1 ether; + } return 0; } diff --git a/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestFakeSponsorship.sol b/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestFakeSponsorship.sol new file mode 100644 index 000000000..4ab3f02b3 --- /dev/null +++ b/packages/network-contracts/contracts/OperatorTokenomics/testcontracts/TestFakeSponsorship.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.13; + +/** + * Minimal "sponsorship" that was NOT deployed by the SponsorshipFactory and that the Operator is NOT staked in. + * Its withdraw() returns whatever number the caller has set, but never transfers any DATA tokens. + * Models the fake sponsorship used in the 2026-10-09 Operator drain: Operator._withdrawEarnings trusted the return value + * of withdraw() and paid out protocol fee + fisherman reward from the Operator's own DATA balance. + */ +contract TestFakeSponsorship { + uint public fakeEarningsWei; + + function setFakeEarnings(uint fakeEarningsWei_) external { + fakeEarningsWei = fakeEarningsWei_; + } + + function withdraw() external view returns (uint payoutWei) { + return fakeEarningsWei; + } +} diff --git a/packages/network-contracts/test/hardhat/OperatorTokenomics/Operator.test.ts b/packages/network-contracts/test/hardhat/OperatorTokenomics/Operator.test.ts index 3ef953d5a..62f609cac 100644 --- a/packages/network-contracts/test/hardhat/OperatorTokenomics/Operator.test.ts +++ b/packages/network-contracts/test/hardhat/OperatorTokenomics/Operator.test.ts @@ -6,7 +6,7 @@ import { advanceToTimestamp, getBlockTimestamp, VOTE_KICK, VOTE_START, log } fro import { deployOperatorContract } from "./deployOperatorContract" import { deploySponsorship } from "./deploySponsorshipContract" -import { IKickPolicy, IExchangeRatePolicy, Operator, Sponsorship, TestToken } from "../../../typechain" +import { IAllocationPolicy, IKickPolicy, IExchangeRatePolicy, Operator, Sponsorship, TestToken } from "../../../typechain" import { setupSponsorships } from "./setupSponsorships" import type { Wallet } from "ethers" @@ -35,6 +35,7 @@ describe("Operator contract", (): void => { let defaultOperator: Operator let defaultSponsorship: Sponsorship let testKickPolicy: IKickPolicy + let testAllocationPolicy: IAllocationPolicy let testExchangeRatePolicy: IExchangeRatePolicy let testExchangeRatePolicy2: IExchangeRatePolicy let testExchangeRatePolicy3: IExchangeRatePolicy @@ -82,7 +83,8 @@ describe("Operator contract", (): void => { token = sharedContracts.token testKickPolicy = await (await (await getContractFactory("TestKickPolicy", admin)).deploy()).deployed() as unknown as IKickPolicy - await (await sharedContracts.sponsorshipFactory.addTrustedPolicies([ testKickPolicy.address])).wait() + testAllocationPolicy = await (await getContractFactory("TestAllocationPolicy", admin)).deploy() as unknown as IAllocationPolicy + await (await sharedContracts.sponsorshipFactory.addTrustedPolicies([ testKickPolicy.address, testAllocationPolicy.address ])).wait() testExchangeRatePolicy = await (await getContractFactory("TestExchangeRatePolicy", admin)).deploy() as IExchangeRatePolicy testExchangeRatePolicy2 = await (await getContractFactory("TestExchangeRatePolicy2", admin)).deploy() as IExchangeRatePolicy @@ -1198,6 +1200,89 @@ describe("Operator contract", (): void => { expect(formatEther(await operator.balanceInData(delegator.address))).to.equal("15700.0") }) + // Regression test for the 2026-10-09 Operator drain: a fake "sponsorship" whose withdraw() returns a made-up number (and sends no DATA) + // was passed to withdrawEarningsFromSponsorships by an outsider. The fake earnings X = freeBalance / (protocolFee + fishermanReward) + // triggered the fisherman path, and 5% + 25% = 30% of X == the whole free DATA balance was paid out of the Operator's own DATA. + // Before the fix (StakeModule version 4) this test's transaction succeeded and the Operator was left with 0 DATA. + it("does NOT pay fisherman reward or protocol fee for a fake sponsorship given to withdrawEarningsFromSponsorships", async () => { + const attacker = (await getSigners())[9] as unknown as Wallet + await setTokens(operatorWallet, "1000") + await setTokens(delegator, "2000") + await setTokens(attacker, "0") + await setTokens(protocolFeeBeneficiary, "0") + const { operator } = await deployOperator(operatorWallet) + await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("1000"), "0x")).wait() + await (await token.connect(delegator).transferAndCall(operator.address, parseEther("2000"), "0x")).wait() + expect(await token.balanceOf(operator.address)).to.equal(parseEther("3000")) // free (unstaked) DATA + expect(await operator.balanceOf(operatorWallet.address)).to.equal(parseEther("1000")) // self-delegation + expect(await operator.balanceOf(delegator.address)).to.equal(parseEther("2000")) + + // attacker deploys a contract that is NOT a Sponsorship: not from SponsorshipFactory, Operator is not staked in it, it never sends DATA + const fakeSponsorship = await (await getContractFactory("TestFakeSponsorship", attacker)).deploy() + await fakeSponsorship.deployed() + // choose X so that protocolFee (5%) + fishermanReward (25%) = 30% of X = 3000 DATA = everything the Operator holds + await (await fakeSponsorship.setFakeEarnings(parseEther("10000"))).wait() + + await expect(operator.connect(attacker).withdrawEarningsFromSponsorships([fakeSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship") + // also not when mixed in with a real sponsorship the operator is staked in + await setTokens(operatorWallet, "5000") + await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("5000"), "0x")).wait() + await (await operator.stake(defaultSponsorship.address, parseEther("5000"))).wait() + await expect(operator.connect(attacker).withdrawEarningsFromSponsorships([defaultSponsorship.address, fakeSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship") + // and not through the triggerAnotherOperatorWithdraw fisherman path either + await expect(defaultOperator.connect(operatorWallet).triggerAnotherOperatorWithdraw(operator.address, [fakeSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship") + + expect(await token.balanceOf(operator.address)).to.equal(parseEther("3000")) // 3000 + 5000 - 5000 staked, nothing drained + expect(await token.balanceOf(attacker.address)).to.equal(parseEther("0")) + expect(await token.balanceOf(protocolFeeBeneficiary.address)).to.equal(parseEther("0")) + expect(await operator.balanceOf(operatorWallet.address)).to.equal(parseEther("6000")) // self-delegation intact + expect(await operator.balanceInData(delegator.address)).to.equal(parseEther("2000")) + }) + + it("does NOT withdraw from a real Sponsorship that the Operator is not staked in", async function(): Promise { + await setTokens(operatorWallet, "10000") + const { operator } = await deployOperator(operatorWallet) + const stakedSponsorship = await deploySponsorship(sharedContracts) + const notStakedSponsorship = await deploySponsorship(sharedContracts) + await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("10000"), "0x")).wait() + await (await operator.stake(stakedSponsorship.address, parseEther("5000"))).wait() + + await expect(operator.withdrawEarningsFromSponsorships([notStakedSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship") + await expect(operator.withdrawEarningsFromSponsorships([stakedSponsorship.address, notStakedSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NotMyStakedSponsorship") + }) + + // Second layer of defense: even a genuine Sponsorship that the Operator IS staked in must not be able to make the Operator + // book earnings that never arrived (Sponsorship._withdraw swallows transferAndCall failures but still returns the payout number) + it("counts withdrawn earnings from the DATA balance change, NOT from what Sponsorship.withdraw() returns", async function(): Promise { + const attacker = (await getSigners())[9] as unknown as Wallet + await setTokens(operatorWallet, "15000") + await setTokens(attacker, "0") + await setTokens(protocolFeeBeneficiary, "0") + const { operator } = await deployOperator(operatorWallet) + // TestAllocationPolicy case 11: withdraw() reports a payout that exceeds the Sponsorship's DATA balance => no DATA is actually sent + const lyingSponsorship = await deploySponsorship(sharedContracts, {}, [], [], testAllocationPolicy, "11") + await (await token.connect(operatorWallet).transferAndCall(operator.address, parseEther("15000"), "0x")).wait() + await (await operator.stake(lyingSponsorship.address, parseEther("5000"))).wait() + expect(await token.balanceOf(operator.address)).to.equal(parseEther("10000")) + const reportedPayout = await lyingSponsorship.connect(admin.provider).callStatic.withdraw({ from: operator.address }) + expect(reportedPayout).to.equal(parseEther("5001")) // the lie: 5000 stake + 1 > what the Sponsorship holds + + await expect(operator.connect(attacker).withdrawEarningsFromSponsorships([lyingSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NoEarnings") + await expect(operator.withdrawEarningsFromSponsorships([lyingSponsorship.address])) + .to.be.revertedWithCustomError(operator, "NoEarnings") + + expect(await token.balanceOf(operator.address)).to.equal(parseEther("10000")) + expect(await token.balanceOf(attacker.address)).to.equal(parseEther("0")) + expect(await token.balanceOf(protocolFeeBeneficiary.address)).to.equal(parseEther("0")) + expect(await operator.balanceOf(operatorWallet.address)).to.equal(parseEther("15000")) + }) + it("can update operator cut fraction for himself, but NOT for others (and not >100%)", async function(): Promise { const { operator, contracts } = await deployOperator(operatorWallet) const operator2 = await deployOperatorContract(contracts, operator2Wallet) From 31096a91a2cfa64381895c34376ff8c16cfa9d95 Mon Sep 17 00:00:00 2001 From: Henri Pihkala Date: Sat, 10 Oct 2026 12:12:13 +0200 Subject: [PATCH 2/3] fix config test --- packages/config/test/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/config/test/index.test.ts b/packages/config/test/index.test.ts index 4319a2aa0..91b857f78 100644 --- a/packages/config/test/index.test.ts +++ b/packages/config/test/index.test.ts @@ -20,7 +20,7 @@ describe("Load configuration from JSON file", () => { }) it("reads prod Polygon RPC URL", () => { const rpcHttpUrl = config.polygon.rpcEndpoints[0].url - const expected = "https://polygon-rpc.com" + const expected = "https://polygon.drpc.org" assert.equal(rpcHttpUrl, expected, `Expecting prod polygon RPC URL to equal ${expected}, got '${rpcHttpUrl}'`) }) }) From 35df78b3a05574c322319633b5f64c5d6a3fd5ff Mon Sep 17 00:00:00 2001 From: Henri Pihkala Date: Sat, 10 Oct 2026 12:39:49 +0200 Subject: [PATCH 3/3] use newer Linux in Dockerfile --- packages/network-subgraphs/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/network-subgraphs/Dockerfile b/packages/network-subgraphs/Dockerfile index a5c8da40b..e78d6446f 100644 --- a/packages/network-subgraphs/Dockerfile +++ b/packages/network-subgraphs/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16-buster +FROM node:20-bookworm # RUN apk add libsecret-dev RUN apt update