https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/tree/f3f31d2ac15e3253cba342229f9d05495f95d6fd
Contract: cUSDO.sol https://github.com/OpenEdenHQ/openeden.usdoexpress.audit/blob/f3f31d2ac15e3253cba342229f9d05495f95d6fd/contracts/tokens/cUSDO.sol#L39
Location: Inherits standard ERC4626Upgradeable without virtual shares offset protection
Type: Griefing Attack / User Fund Loss / ERC4626 Inflation Vulnerability
cUSDO uses standard OpenZeppelin ERC4626Upgradeable implementation with default _decimalsOffset() = 0, which provides minimal virtual shares protection but is insufficient to prevent user fund loss. Despite OpenZeppelin's virtual offset mechanism making the attack "non-profitable" for attackers, victims still lose 9-25% of their deposits.
This is a griefing attack where attacker loses money (-20K USDO) but causes 10K+ losses for victims. The default offset=0 adds +1 virtual share to calculations, preventing attacker profit but not preventing victim losses.
cUSDO inherits standard ERC4626 with default _decimalsOffset() = 0:
// cUSDO.sol:38-45
contract cUSDO is
ERC4626Upgradeable, // ⚠️ Uses offset=0 (insufficient protection)
AccessControlUpgradeable,
PausableUpgradeable,
UUPSUpgradeable,
IERC20PermitUpgradeable,
EIP712Upgradeable
{
// Inherits _decimalsOffset() = 0 from OpenZeppelin
// This adds +1 virtual share/asset but is INSUFFICIENT
// No minimum first deposit requirement
// No checks against donation attacks
}
OpenZeppelin ERC4626 Share Calculation with offset=0:
// OpenZeppelin ERC4626Upgradeable internal functions:
function _convertToShares(uint256 assets) internal view returns (uint256) {
// offset=0 means 10^0 = 1 virtual share added
return assets * (totalSupply + 1) / (totalAssets + 1);
}
function _convertToAssets(uint256 shares) internal view returns (uint256) {
// offset=0 means 10^0 = 1 virtual asset added
return shares * (totalAssets + 1) / (totalSupply + 1);
}
// With 1 wei initial deposit and 50K donation:
totalAssets = 50,000e18 + 1
totalSupply = 1
// Victim deposits 100K USDO with virtual offset:
shares = 100,000e18 * (1 + 1) / (50,000e18 + 1 + 1)
shares = 100,000e18 * 2 / 50,000e18
shares = 4 (but victim only gets 3 after rounding)
// Attacker redeems 1 share:
assets = 1 * (150,000e18 + 1) / (4 + 1)
assets = 30,000e18 (attacker gets 30K, loses 20K net)
// Victim gets remaining: 90K (loses 10K)
The Problem:
Description: Attacker deposits 1 wei, donates 50K USDO, victim loses 10K (9% of 100K deposit).
Test: test/cUSDO_InflationAttack.t.sol::test_InflationAttack_BasicScenario()
Step 1: Attacker deposits 1 wei and donates 50K USDO
cusdo.deposit(1, attacker) → 1 share
usdo.transfer(cusdo, 50_000e18) → donation
Result:
totalAssets = 50,000 USDO
totalSupply = 1 share
Price per share = 50,000 USDO
Step 2: Victim deposits 100K USDO
cusdo.deposit(100_000 USDO, victim)
Calculation with offset=0:
shares = 100,000 * (1 + 1) / (50,000 + 1)
shares = 100,000 * 2 / 50,000 = 4
But victim only gets 3 shares after rounding!
Expected: 100,000 shares
Actual: 3 shares
Step 3: Both redeem
Attacker redeems 1 share:
assets = 1 * 150,000 / 4 = 30,000 USDO
Loss: 50,000 - 30,000 = -20,000 USDO ❌
Victim redeems 3 shares:
assets = 3 * 120,000 / 3 = 90,000 USDO
Loss: 100,000 - 90,000 = -10,000 USDO ❌
Result:
Attacker: -20,000 USDO (griefing cost)
Victim: -10,000 USDO (9% loss)
This is a GRIEFING attack - attacker loses to damage victims
File: test/cUSDO_InflationAttack.t.sol
Run Tests:
forge test --match-contract cUSDOInflationAttackTest -vv
Test Results:
[PASS] test_InflationAttack_BasicScenario()
Victim loss: 9,999 USDO (9%)
Attacker loss: 20,000 USDO (griefing)
[PASS] test_InflationAttack_SevereRounding()
Victim loss: 10,000 USDO (25%)
Worst case confirmed
[PASS] test_InflationAttack_MitigationWorks()
With admin initial deposit: victim loss ~0
[PASS] test_VerifyInsufficientOffset()
Confirmed: offset=0 adds +1 but insufficient
This vulnerability demonstrates how insufficient virtual shares offset (offset=0) causes user fund loss:
OpenZeppelin's Assessment:
"While not fully preventing the attack, analysis shows that the default offset (0) makes it non-profitable"
This confirms offset=0 exists but is insufficient - attack still causes victim losses.
Real-World Probability:
Deploy cUSDO with admin making first deposit:
// During deployment
1. Deploy cUSDO
2. Admin deposits 1,000 USDO initially
3. cUSDO now has sufficient liquidity to prevent inflation
// Result: Price manipulation becomes impractical
Benefits:
Alternative: Increase Virtual Offset
contract cUSDO is ERC4626Upgradeable {
function _decimalsOffset() internal pure virtual override returns (uint8) {
return 3; // 10^3 = 1000 virtual shares (vs default 10^0 = 1)
}
}
This makes attack "orders of magnitude more expensive" (per OpenZeppelin docs).