Reproduced Exploit
Liquity V2 ActivePool `urgentRedemption` — permissionless extraction of collateral from shut-down undercollateralized troves at a +2% bonus
Liquity V2 lets each collateral branch (e.g. the sUSDe branch) be shut down in an emergency. When a branch is shut down the TroveManager opens a public urgentRedemption(BOLD, troveIds[], minColl) path whose only gate is _requireIsShutDown() — no caller whitelist, no slippage/oracle check on the bon…
Loss
~2,696.49 USD (1.0385 ETH per on-chain transaction) output.txt:1562
Chain
Ethereum
Category
Oracle Manipulation
Date
Jul 2025
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2025-07-ActivePoolUrgentRedemption_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/ActivePoolUrgentRedemption_exp.sol.
Vulnerability classes: vuln/logic/incorrect-state-transition · vuln/access-control/missing-auth · vuln/defi/slippage · vuln/oracle/price-calculation Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified source for the
TroveManagerandActivePoolwas fetched from Etherscan into sources/.
Key info#
| Loss | ~2,696.49 USD (1.0385 ETH per on-chain transaction) output.txt:1562 |
| Vulnerable contract | TroveManager (sUSDe branch) — 0x9dc845b500853F17E238C36Ba120400dBEa1D02A; collateral held in ActivePool — 0xdEE8A9AC2C2819fe6A3Bae45a12bff70c604805a |
| Attacker EOA | 0xc8d64BB25b489ba3Fb33f1f81505e8938685c248 |
| Attack contract | Historical executor 0xe2bE83aa486F7a921849ff71DCE9B9c18A4B5db2 (deployed via 0xD94b9aD4918015724D5F68501F3252Bb29505243) |
| Attack tx | 0xc8ac54bdab9a2ce670a6ac2540e07dc88b5eeb7e0306e86d1d66f584428b3e0d |
| Chain / block / date | Ethereum mainnet / 22,834,887 / 2025-07-03 |
| Compiler | solc 0.8.24 (from _meta.json) |
| Bug class | A shut-down collateral branch exposes a permissionless urgentRedemption that pays a 2% collateral bonus on top of par and skips the ICR≥100% floor, so any caller can burn BOLD for collateral worth more than the BOLD they surrender. |
TL;DR#
Liquity V2 lets each collateral branch (e.g. the sUSDe branch) be shut down in an emergency. When a branch is shut down the TroveManager opens a public urgentRedemption(BOLD, troveIds[], minColl) path whose only gate is _requireIsShutDown() — no caller whitelist, no slippage/oracle check on the bonus sources/TroveManager_9dc845/src_TroveManager.sol:874. The redemption math grants the redeemer collLot = boldLot * (1 + URGENT_REDEMPTION_BONUS) / price where URGENT_REDEMPTION_BONUS = 2e16 (2%) sources/TroveManager_9dc845/src_Dependencies_Constants.sol:74. Unlike the normal redemption path, the urgent path has no ICR < _100pct skip — it happily redeems from troves that are under water, paying the 2% bonus on every single one sources/TroveManager_9dc845/src_TroveManager.sol:858 (compare the floor at :790).
The attacker weaponised this atomically with a flash loan. They flash-borrowed 108,500 USDT from Morpho, routed it USDT → BOLD via two Curve pools, burned the BOLD through urgentRedemption against four selected sUSDe troves, received 94,435.34 sUSDe worth of collateral for only ~109,016 BOLD output.txt:1866, swapped the sUSDe back to 111,194.7 USDT on Fluid DEX output.txt:1948, repaid the 108,500 USDT flash loan, and converted the ~2,694.7 USDT surplus to 1.0385 WETH (≈$2,696) via Uniswap V3, withdrawing it as ETH to the attacker EOA output.txt:1962, output.txt:1991.
The mechanism is not a reentrancy or an oracle break — it is a design flaw in the shutdown-mode accounting: the 2% "urgent redemption bonus" plus the removal of the ICR floor turns BOLD into a claim on more collateral than it is worth, for any trove whose collateral market value (sUSDe→USDT) exceeds (BOLD price / sUSDe price) * 1.02. The sUSDe branch was shut down precisely because its troves had become unprofitable to liquidate, which is exactly the state in which this bonus becomes extractable.
Background — what Liquity V2 (BOLD) does#
Liquity V2 is the successor to the original Liquity stablecoin protocol. Each collateral branch (ETH/wstETH/rETH/sUSDe/…) is a separate TroveManager instance backing the same peg-stablecoin BOLD (0x85E3…579dA). Users open Troves, deposit a collateral token, and borrow BOLD against it. Each Trove is an NFT (troveId). The protocol maintains an ActivePool (holds the actual collateral tokens for the branch) and a DefaultPool (holds liquidation/redistribution proceeds).
Two safety valves exist:
-
Normal redemption (
redeemCollateral) — open while the branch is live. A redeemer burns BOLD and receives collateral at face value (price), iterating over the lowest-ICR troves first. Crucially, it skips any trove whose ICR is< 100%so redemptions never decrease the collateral ratio of the troves they hit sources/TroveManager_9dc845/src_TroveManager.sol:790. Redemptions are the peg-keeping mechanism: if BOLD trades below $1, arbitrageurs burn BOLD for $1 of collateral, restoring the peg. -
Shutdown (
shutdown()) +urgentRedemption— emergency-only.BorrowerOperationscallsTroveManager.shutdown(), which setsshutdownTimeand flips the ActivePool shutdown flag sources/TroveManager_9dc845/src_TroveManager.sol:934. Once shut down, normal redemptions stop andurgentRedemptionopens. This path is meant to let BOLD holders exit a dying branch quickly: any caller picks specific troveIds, burns BOLD, and pulls collateral directly out of the ActivePool sources/TroveManager_9dc845/src_TroveManager.sol:929.
The urgent redemption bonus (URGENT_REDEMPTION_BONUS = 2%) was an incentive to encourage fast unwinding of shut-down branches. The flaw is that this bonus is paid unconditionally per-trove, with no floor, while the branch is in the exact state where the collateral has lost value — so the bonus is amplified rather than compensated by the bonus's intended purpose.
The vulnerable code#
urgentRedemption — the permissionless shutdown entry point#
// sources/TroveManager_9dc845/src_TroveManager.sol:874
function urgentRedemption(uint256 _boldAmount, uint256[] calldata _troveIds, uint256 _minCollateral) external {
_requireIsShutDown(); // only gate: branch must be shut down
_requireAmountGreaterThanZero(_boldAmount);
_requireBoldBalanceCoversRedemption(boldToken, msg.sender, _boldAmount); // caller must hold the BOLD — anyone can
IActivePool activePoolCached = activePool;
TroveChange memory totalsTroveChange;
(uint256 price,) = priceFeed.fetchPrice(); // standard (not redemption) price
uint256 remainingBold = _boldAmount;
for (uint256 i = 0; i < _troveIds.length; i++) {
...
if (!_isActiveOrZombie(Troves[singleRedemption.troveId].status) || singleRedemption.trove.entireDebt == 0) {
continue; // no ICR >= 100% floor here!
}
...
_urgentRedeemCollateralFromTrove(defaultPool, remainingBold, price, singleRedemption);
totalsTroveChange.collDecrease += singleRedemption.collLot;
...
}
...
// Send the redeemed coll to caller
activePoolCached.sendColl(msg.sender, totalsTroveChange.collDecrease);
boldToken.burn(msg.sender, totalsTroveChange.debtDecrease);
}
Note the contrast with the live-mode redemption at line 790, which explicitly skips troves with ICR < _100pct:
// sources/TroveManager_9dc845/src_TroveManager.sol:788-790 (normal redemption)
// Skip if ICR < 100%, to make sure that redemptions don't decrease the CR of hit Troves.
// Use the normal price for the ICR check.
if (getCurrentICR(singleRedemption.troveId, _price) < _100pct) {
The urgent path omits that guard. Anyone can hand it a list of the worst (lowest-ICR) troves and redeem out of them.
The bonus math — _urgentRedeemCollateralFromTrove#
// sources/TroveManager_9dc845/src_TroveManager.sol:848-864
function _urgentRedeemCollateralFromTrove(
IDefaultPool _defaultPool,
uint256 _maxBoldamount,
uint256 _price,
SingleRedemptionValues memory _singleRedemption
) internal {
// cap the BOLD lot to the trove's entire debt
_singleRedemption.boldLot = LiquityMath._min(_maxBoldamount, _singleRedemption.trove.entireDebt);
// BOLD lot valued at (price + 2% bonus) -> MORE collateral than 1:1
_singleRedemption.collLot =
_singleRedemption.boldLot * (DECIMAL_PRECISION + URGENT_REDEMPTION_BONUS) / _price;
// ... cap at the trove's entire collateral if needed ...
}
With URGENT_REDEMPTION_BONUS = 2e16 and DECIMAL_PRECISION = 1e18 sources/TroveManager_9dc845/src_Dependencies_Constants.sol:74, the multiplier is 1.02e18. Burning 1 BOLD therefore yields 1.02 / price units of collateral — i.e. 102% of the USD value, not 100%.
ActivePool.sendColl — the payoff sink#
// sources/ActivePool_dee8a9/src_ActivePool.sol:162-168
function sendColl(address _account, uint256 _amount) external override {
_requireCallerIsBOorTroveMorSP(); // TroveManager is whitelisted -> the call passes
_accountForSendColl(_amount); // collBalance -= _amount
collToken.safeTransfer(_account, _amount); // sUSDe leaves the pool to the caller
}
sendColl trusts the TroveManager, and the TroveManager trusts any caller once shut down. There is no per-trove collateral-adequacy check at the point of transfer.
The shutdown trigger#
// sources/TroveManager_9dc845/src_TroveManager.sol:934-938
function shutdown() external {
_requireCallerIsBorrowerOperations();
shutdownTime = block.timestamp;
activePool.setShutdownFlag();
}
Once governance/ops triggers a branch shutdown via BorrowerOperations, urgentRedemption is live for everyone, forever — there is no time window, no rate limit, and no caller ACL.
Root cause — why it was possible#
- Missing access control on
urgentRedemption. The only guard is_requireIsShutDown()src_TroveManager.sol:875. Any EOA/contract can call it once a branch is shut down — the shutdown path was implicitly assumed to be "safe to open" because redemptions are normally self-balancing, but that assumption breaks under (2) and (3). - Unconditional 2% collateral bonus on a per-trove basis.
URGENT_REDEMPTION_BONUS = 2%is applied to every urgent redemption lot src_TroveManager.sol:858. The bonus turns BOLD from "a claim on $1 of collateral" into "a claim on $1.02 of collateral," so any market where sUSDe is worth ≥1/1.02of BOLD is a profit opportunity. - No ICR floor on the urgent path. The live-redemption
ICR < _100pctskip src_TroveManager.sol:790 is absent from_urgentRedeemCollateralFromTrove. The attacker can cherry-pick the troves with the best collateral-per-BOLD ratio (here, four specific sUSDe troves) and drain them, rather than being forced to take the sorted, lowest-ICR queue used by normal redemption. - Bonus paid in collateral market value while debt is settled at oracle value.
priceis the sUSDe/USD oracle price used to size the collateral lot, but the attacker realises the collateral at the sUSDe→USDT spot price via Fluid DEX. Because the sUSDe branch was shut down due to stress, the oracle/market basis had moved enough that1.02 * sUSDe_collateral / BOLD > 1in USD terms — the trade printed a net USD surplus with no slippage protection on the protocol side (minCollateralwas 0). - Permissionless composability with flash loans. The entire input side (USDT flash-borrow, USDT→BOLD via Curve) is atomic, so the attack requires no upfront capital and no price movement — the profit is locked in within one transaction.
Preconditions#
- Branch must be shut down. The sUSDe branch was in
shutdownTime != 0state (set by BorrowerOperations). This is the sole protocol-side precondition. No privileged role is required from the attacker. - Attacker must be able to source BOLD and sell sUSDe in the same transaction. Satisfied via a Morpho USDT flash loan + Curve (USDT/BOLD, then BOLD LP) + Fluid DEX (sUSDe→USDT) + Uniswap V3 (USDT→WETH). No special status on any of these venues.
- Profitability condition:
sUSDe_price_in_USD * 1.02 ≥ BOLD_price_in_USDafter swap fees. Held at block 22,834,887 because the sUSDe branch had been shut down amid collateral devaluation, which is exactly when the bonus becomes extractable. - Permissionless — fully. No admin key, no governance vote, no whitelisting.
Attack walkthrough (with on-chain numbers from the trace)#
All figures from output.txt. Attacker starting ETH balance: 0 output.txt:1564.
| # | Step | Call | Amount in → Amount out |
|---|---|---|---|
| 1 | Flash-loan USDT | Morpho.flashLoan(USDT, 108_500e6, "") | +108,500 USDT borrowed output.txt:1621 |
| 2 | USDT → BOLD (2-hop Curve) | Curve USDT/BOLD.add_liquidity([0, 108.5k]) then Curve BOLD.exchange(1→0, lp) | 108,500 USDT → 107,595.56 LP output.txt:1709 → 109,016.29 BOLD output.txt:1797 |
| 3 | Burn BOLD for sUSDe | TroveManager.urgentRedemption(109,016.29 BOLD, [4 troveIds], minColl=0) | 109,016.29 BOLD burned → 94,435.34 sUSDe drawn from ActivePool output.txt:1857, output.txt:1866. Redemption event: BOLD 109,016.29, coll 94,435.34, fee 0 output.txt:1857. |
| 4 | sUSDe → USDT | Fluid DEX.swapIn(true, 94,435.34 sUSDe) | 94,435.34 sUSDe → 111,194.7 USDT output.txt:1948 |
| 5 | Repay flash loan | approve + Morpho pulls 108,500 USDT | −108,500 USDT output.txt:2002 |
| 6 | USDT surplus → WETH | UniswapV3.exactInputSingle(USDT→WETH, fee 0.3%) | 2,694.696 USDT → 1.038522285394433553 WETH output.txt:1962, output.txt:1980 |
| 7 | WETH → ETH | WETH.withdraw(1.0385…e18) | +1.0385 ETH to attack contract output.txt:1991 |
| 8 | Forward to attacker | profitReceiver.transfer(1.0385 ETH) | attacker balance 0 → 1.0385 ETH output.txt:2014 |
Profit/loss accounting:
- sUSDe collateral received for 109,016.29 BOLD: 94,435.34 sUSDe ≈ 111,194.7 USDT (after DEX swap)
- Cost to acquire 109,016.29 BOLD: 108,500 USDT (flash loan principal)
- Gross surplus: 111,194.7 − 108,500 = 2,694.7 USDT
- Realised as: 1.0385 ETH ≈ $2,696.49 at the on-chain ETH price
- Flash-loan fee: 0 (Morpho flash loan repays principal only here)
- Net profit to attacker: ~$2,696.49 / 1.0385 ETH, exactly matching
assertEq(ATTACKER.balance − before, 1_038_522_285_394_433_553)output.txt:1562
The 2,694.7 USDT surplus is the on-chain quantification of the 2% bonus plus the undercollateralised-trove cherry-pick: the protocol surrendered ~2.47% more USD of collateral than the BOLD it burned.
Diagrams#
Attack sequence#
Flaw logic#
Remediation#
- Cap or remove the bonus. A 2% unconditional bonus is only defensible if redemptions are forced to take the worst troves in order. Either set
URGENT_REDEMPTION_BONUS = 0for shut-down branches, or make it decay to zero over a short window aftershutdownTime(e.g. linear decay over 1 hour) so it cannot be farmed. - Restore the ICR floor on the urgent path. Port the
if (getCurrentICR(troveId, price) < _100pct) continue;guard fromredeemCollateralinto_urgentRedeemCollateralFromTrove. Troves below 100% should be liquidated, not redeemed at a bonus. - Make
urgentRedemptionenforce ordering. Force the caller to redeem against the lowest-ICR active/zombie troves first (re-use the sorted redemption queue), removing the ability to cherry-pick the most profitable troves. - Enforce a meaningful
minCollateral/ max-rate. TreaturgentRedemptionlike a swap: compute the implied BOLD→collateral rate and require_minCollateralto be non-zero and above the oracle-implied 1:1 rate (rejecting the 2% bonus if it exceeds acceptable bounds). Currently the PoC passesminCollateral = 0. - Add a shutdown grace period + rate limit. After
shutdown(), allow only a bounded amount of collateral to leave per block (or per caller), so any mispriced bonus cannot be drained in a single transaction. - Sanity-check the bonus against the collateral's market/DEX price at shutdown time. If
oracle_price * 1.02 > market_price, the bonus is a guaranteed drain — refuse to openurgentRedemptionuntil the basis has normalised, or compute the bonus from the lower of oracle and a fresh DEX TWAP.
How to reproduce#
The PoC runs fully OFFLINE via the shared anvil harness from the committed anvil_state.json — no RPC needed.
_shared/run_poc.sh 2025-07-ActivePoolUrgentRedemption_exp -vvvvv
- Chain / fork block: Ethereum mainnet (chain id 1) at block 22,834,887 (
vm.createSelectForkagainst the local anvil instance seeded byanvil_state.json;vm.warp(1_751_499_707)). - Expected result:
[PASS] testExploit()output.txt:1562, with:Attacker Before exploit ETH Balance: 0.000000000000000000output.txt:1564Attacker After exploit ETH Balance: 1.038522285394433553output.txt:1565- The closing
assertEqof1.0385…e18ETH profit againstHISTORICAL_ETH_PROFITpasses.
- The PoC
vm.etches the locally-compiled attack contract onto the historical executor address so the on-chain call sequence is replayed exactly against the forked state.
Reference: Telegram alert https://t.me/defimon_alerts/1379.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-07-ActivePoolUrgentRedemption_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
ActivePoolUrgentRedemption_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Liquity V2 ActivePool
urgentRedemption". - 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.