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.
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 = 1e6to 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
currentWithheldETHaccounting - 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: HIGH
-
Total Fund Loss: 100% of user deposits can be extracted.
-
No Recovery Mechanism: Once extracted, funds cannot be recovered.
-
Token Collapse: frxETH becomes unbacked and worthless.
-
Protocol Death: Complete loss of user trust, protocol unusable.
-
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: LOW-MEDIUM
Mitigating Factors:
- Frax is established protocol with reputation
- Multisig likely used for owner (not verified on-chain)
- Economic incentive to maintain trust
Risk Factors:
- No on-chain enforcement of governance
- Single point of failure if key compromised
- No timelock allows instant extraction
- 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)
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
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;
}