Reproduced Exploit

Balmy: cToken collateral is priced 1e10 too low and 1 DAI seizes 2e8 DAI

_convertSharesToAssets divides shares exchangeRateStored by 10 (10 + underlyingDecimals). For DAI that denominator is 1028. The Compound exchange rate is already scaled so the true redeem value divides by 10*18. The position is priced at 2e16 instead of 2e26. A 1 DAI debt is above the buggy price,…

Nov 2024•Ethereum•arithmetic•4 min read

Loss

Liquidator pays 1 DAI and receives 2e26 wei of DAI. Net gain is 1.99999999e8 DAI

Chain

Ethereum

Category

arithmetic

Date

Nov 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 46430-compoundv2connector-convertsharestoassets-magnitude-scalar-c. Standalone Foundry PoC and full write-up: 46430-compoundv2connector-convertsharestoassets-magnitude-scalar-c_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/arithmetic/decimal-mismatch · vuln/logic/price-calculation

Key info#

FieldValue
LossLiquidator pays 1 DAI and receives 2e26 wei of DAI. Net gain is 1.99999999e8 DAI
Vulnerable contractCompoundV2Connector._convertSharesToAssets
Attacker EOA0x0000000000000000000000000000000000046430
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2024-11
Compilersolc 0.8.27, cancun, via IR
Bug classMagnitude is 10 + decimals instead of 18

TL;DR#

_convertSharesToAssets divides shares * exchangeRateStored by 10 ** (10 + underlyingDecimals). For DAI that denominator is 10**28. The Compound exchange rate is already scaled so the true redeem value divides by 10**18. The position is priced at 2e16 instead of 2e26. A 1 DAI debt is above the buggy price, so liquidate repays 1 DAI and transfers the true 2e26 of collateral.

Background#

Cantina's November 2024 Balmy review, CompoundV2Connector.sol lines 378-383. The report's fork PoC on cDAI shows a computed redeem amount that is 10 digits below the DAI actually returned by cToken.redeem. cTokens have 8 decimals. The exchange-rate mantissa is 18 + underlyingDecimals - 8, which is 28 for an 18-decimal underlying, and the share conversion must still divide by 10**18 after multiplying by that rate, not by 10**28.

The vulnerable code#

SOLIDITY
uint256 underlyingTokenDecimals = _max(assetDecimals, 8);
uint256 magnitude = (10 + underlyingTokenDecimals);
// @> VULN: magnitude is 10 + decimals (28 for DAI) instead of 18, so the cToken is worth 1e10 too little
return shares * exchangeRate / (10 ** magnitude);

With shares = 1e16 and exchangeRate = 2e28, the function returns 2e16. shares * exchangeRate / 1e18 is 2e26. The ratio is exactly 1e10.

Root cause#

The magnitude adds a fixed 10 to the underlying decimals. That double-counts the 8 cToken decimals plus an extra 2. The division is too large by 10**10 for every 18-decimal underlying, which is the gap the cDAI fork PoC prints.

Preconditions#

  • The connector's asset is DAI, 18 decimals, so magnitude is 28.
  • The position holds 1e16 cTokens at exchange rate 2e28. The connector's DAI balance is the true redeem value, 2e26.
  • Debt is 1 ether, which is greater than the buggy price 2e16 and far below the true value.

Attack walkthrough#

  1. run asks _convertSharesToAssets for the position. The result is 2e16.
  2. liquidate requires debt > priced. 1 ether is greater than 2e16, so the position is treated as underwater.
  3. The attacker transfers 1 DAI into the connector.
  4. The connector pays cBalance * exchangeRate / 1e18, which is 2e26 DAI.
  5. The attacker's DAI balance is 2e26. It started the call holding 1 DAI.

Diagrams#

flowchart TD A["1e16 cTokens at exchange rate 2e28"] --> B["VULN divides by 10 to the 28"] B --> C["Quoted collateral is 2e16"] C --> D["1 DAI of debt is treated as insolvent"] D --> E["Liquidator repays 1 DAI"] E --> F["Connector pays the true 2e26 DAI"] B --> G["Control divides by 10 to the 18"] G --> H["Control sees 2e26 of collateral and reverts the liquidation"]

Impact#

The liquidator spends 1 DAI and receives 200000000000000000000000000 wei. The net gain during run is 199999999000000000000000000 wei. That DAI was the collateral backing the position. The control uses magnitude 18, prices the same position at 2e26, and liquidate reverts with solvent. The caller's 1 DAI is untouched.

Remediation#

Divide by 10**18. The review's note is that cTokens have 8 decimals, so the extra 10 + decimals term is the wrong scale. The same fix has to be applied anywhere exchangeRateStored is turned into an underlying amount.

How to reproduce#

BASH
cd 46430-compoundv2connector-convertsharestoassets-magnitude-scalar-c_exp
forge test -vv

test_exploit_undervalued_ctoken_is_liquidated_for_1_dai asserts the 1e10 gap and the DAI gain. test_control_magnitude_18_is_not_liquidatable expects solvent. Captured output is output.txt.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.