Alkemi Earn Public — Self-Liquidation via Storage Aliasing

On March 10, 2026, an attacker exploited the liquidateBorrow function of the Alkemi Earn Public lending protocol on Ethereum mainnet (block 24,626,979) to self-liquidate their own solvent position. The root cause is a compound vulnerability: liquidateBorrow lacks both a self-liquidation guard (require(msg.sender != targetAccount)) and an insolvency enforcement check. When the borrower and liquidator are the same address and the same asset is used for both borrow and collateral, two storage pointer aliases refer to the same storage slot, causing the supply balance write for the “borrower” to be immediately overwritten by the write for the “liquidator,” resulting in a net credit of supply + seize instead of just supply. The attacker supplied 50 ETH, borrowed 39.5 ETH, self-liquidated their healthy position to inflate their supply balance to 93.5 ETH, and withdrew all collateral — earning approximately 43.45 ETH ($108,600 at ~$2,500/ETH) in profit.

Root Cause

Vulnerable Contract

  • Name: AlkemiEarnPublic
  • Proxy address: 0x4822d9172e5b76b9db37b75f5552f9988f98a888
  • Implementation address: 0x85a948fd70b2b415bda93324581fb5fff1293df7
  • Proxy type: EIP-1967 transparent proxy; all execution occurs in the implementation
  • Source type: Verified (Etherscan)

Vulnerable Function

  • Function: liquidateBorrow(address targetAccount, address assetBorrow, address assetCollateral, uint256 requestedAmountClose)
  • Selector: 0xe61604cf
  • File: 0x85a948fd70b2b415bda93324581fb5fff1293df7.sol, line 3444

Vulnerable Code

// [AlkemiEarnPublic implementation — 0x85a948fd70b2b415bda93324581fb5fff1293df7.sol, line 3444]

function liquidateBorrow(
    address targetAccount,
    address assetBorrow,
    address assetCollateral,
    uint256 requestedAmountClose
) public payable returns (uint256) {
    if (paused) {
        return fail(Error.CONTRACT_PAUSED, FailureInfo.LIQUIDATE_CONTRACT_PAUSED);
    }
    if(assetBorrow == wethAddress && requestedAmountClose != msg.value) {
        revertEtherToUser(msg.sender, msg.value);
        revert("ETHER_AMOUNT_MISMATCH_ERROR");
    }
    // <-- VULNERABILITY 1: No check that msg.sender != targetAccount.
    //     A user can liquidate themselves, gaining the liquidation discount on their own position.

    refreshAlkSupplyIndex(assetCollateral, targetAccount, false);
    refreshAlkSupplyIndex(assetCollateral, msg.sender, false);  // When self-liquidating, same address
    refreshAlkBorrowIndex(assetBorrow, targetAccount, false);

    LiquidateLocalVars memory localResults;
    localResults.targetAccount = targetAccount;
    localResults.assetBorrow   = assetBorrow;
    localResults.liquidator    = msg.sender;           // <-- same as targetAccount
    localResults.assetCollateral = assetCollateral;

    // ...interest rate accrual calculations omitted for brevity...

    // Storage pointers for the borrower's collateral balance:
    Balance storage supplyBalance_TargetCollateralAsset =
        supplyBalances[targetAccount][assetCollateral];      // supplyBalances[ATTACKER][WETH_GATEWAY]

    // Storage pointer for the liquidator's collateral balance:
    Balance storage supplyBalance_LiquidatorCollateralAsset =
        supplyBalances[localResults.liquidator][assetCollateral]; // supplyBalances[ATTACKER][WETH_GATEWAY]
    // <-- VULNERABILITY 2 (Storage Aliasing): When targetAccount == msg.sender AND
    //     assetBorrow == assetCollateral, both storage pointers reference the SAME
    //     storage slot. The function reads both before writing, then writes them in sequence.
    //     The liquidator write overwrites the borrower write.

    // ... (calculations use in-memory copies, so aliasing does not affect them) ...

    // After all calculations (in memory):
    //   updatedSupplyBalance_TargetCollateralAsset  = supplyCurrent - seizeAmount  (e.g. 50 - 43.49 = 6.51 ETH)
    //   updatedSupplyBalance_LiquidatorCollateralAsset = supplyCurrent + seizeAmount  (e.g. 50 + 43.49 = 93.49 ETH)

    // EFFECTS — write back to storage:
    // Write 1 (borrower): sets the shared slot to (supply - seize)
    supplyBalance_TargetCollateralAsset.principal = localResults.updatedSupplyBalance_TargetCollateralAsset;
    supplyBalance_TargetCollateralAsset.interestIndex = localResults.newSupplyIndex_CollateralAsset;

    // Write 2 (liquidator): OVERWRITES Write 1 on the same storage slot with (supply + seize)
    supplyBalance_LiquidatorCollateralAsset.principal = localResults.updatedSupplyBalance_LiquidatorCollateralAsset; // <-- VULNERABILITY
    supplyBalance_LiquidatorCollateralAsset.interestIndex = localResults.newSupplyIndex_CollateralAsset;
    // Final result: slot holds (supply + seize) = 93.49 ETH instead of the intended 6.51 ETH
    // The protocol's totalSupply is also inflated, but there is no invariant that prevents withdrawal.
}

Additionally, there is no insolvency check in liquidateBorrow. The function delegates max-closeable amount computation to calculateDiscountedRepayToEvenAmount, which reads the target’s shortfall but uses closeFactorMantissa * borrowBalance as the numerator in the division — not the shortfall itself. A fully solvent borrower therefore passes through the max-closeable calculation with a non-zero permitted amount:

// [line 4046 — calculateDiscountedRepayToEvenAmount]
// accountShortfall_TargetUser is 0 for a solvent borrower, but it is NEVER checked.
// The cap is derived from closeFactorMantissa * borrowBalance, not from shortfall.
uint256 borrowBalance = getBorrowBalance(targetAccount, assetBorrow);
(err, maxClose) = mulScalar(Exp({mantissa: closeFactorMantissa}), borrowBalance);
// ...
(err, rawResult) = divExp(maxClose, discountedPrice_UnderwaterAsset);  // <-- shortfall not used
return (Error.NO_ERROR, truncate(rawResult));
// No require/revert if accountShortfall_TargetUser == 0.  // <-- VULNERABILITY

Why It’s Vulnerable

Expected behavior: liquidateBorrow should (a) require that the liquidator is not the borrower (msg.sender != targetAccount), and (b) require that the borrower’s account is actually underwater (accountShortfall > 0) before permitting liquidation. These two guards are standard in all major lending protocol forks (Compound, Aave, etc.).

Actual behavior: Neither guard exists. When msg.sender == targetAccount and assetBorrow == assetCollateral, the two Balance storage pointers supplyBalance_TargetCollateralAsset and supplyBalance_LiquidatorCollateralAsset resolve to identical storage slots. The function reads both slot values into memory correctly (both reflect the same 50 ETH supply), computes updatedTarget = 50 - seize and updatedLiquidator = 50 + seize separately in memory, then writes both to the same slot sequentially. The liquidator write wins (last write takes precedence), so the slot ends up holding supply + seize — effectively minting ETH collateral credit out of thin air.

Why this matters: An attacker with any capital can:

  1. Supply as collateral,
  2. Borrow up to the permitted LTV,
  3. Pay back the borrowed amount (via liquidateBorrow) to trigger the storage alias,
  4. Withdraw collateral far in excess of what was deposited — draining the protocol’s ETH reserves.

Normal flow vs attack flow for assetBorrow == assetCollateral:

StepNormal (two different users)Attack (self-liquidation)
Borrower slot after liquidationsupply - seize (reduced)Written, then overwritten
Liquidator slot after liquidation0 + seize (gained)supply + seize (wins)
Net protocol supply liabilityUnchanged (transfer)Increased by supply

Attack Execution

High-Level Flow

  1. Attacker EOA (0x0ed1c01b8420a965d7bd2374db02896464c91cd7) deploys attack contract (0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b) and calls attack(50 ETH, ~39.36 ETH).
  2. Attack contract calls Balancer V2 Vault (0xba12...f2c8) to flash-borrow 51 WETH.
  3. Inside the receiveFlashLoan callback, attack contract unwraps 51 WETH → 51 ETH via WETH.withdraw.
  4. Attack contract calls AlkemiEarnPublic.supply(AlkemiWETHGateway, 50 ETH), depositing 50 ETH as collateral into the protocol. Protocol mints an internal supply balance of 50 ETH against the attack contract’s address.
  5. Attack contract calls AlkemiEarnPublic.borrow(AlkemiWETHGateway, 39.5 ETH), borrowing 39.5 ETH (the protocol immediately returns ETH via AlkemiWETHGateway.withdraw; the borrow balance accrues interest to 39.5395 ETH by step 6).
  6. Attack contract queries getBorrowBalance to obtain the exact current borrow balance (39.5395 ETH with accrued interest), then calls AlkemiEarnPublic.liquidateBorrow(attackerContract, WETH_GATEWAY, WETH_GATEWAY, 39.5395 ETH) sending exactly 39.5395 ETH as msg.value. The function performs the self-liquidation: borrow balance is zeroed, and the supply balance slot is inflated from 50 ETH to ~93.49 ETH due to storage aliasing.
  7. Attack contract calls AlkemiEarnPublic.withdraw(AlkemiWETHGateway, uint256.max), withdrawing the full 93.49 ETH supply balance. The AlkemiWETHGateway sends 93.49 ETH back to the attack contract.
  8. Attack contract wraps 51 ETH back to WETH (WETH.deposit), transfers 51 WETH to Balancer to repay the flash loan (zero fee).
  9. Attack contract sends 43.45 ETH profit to the attacker EOA.

Detailed Call Trace

[CALL] AttackerEOA (0x0ed1..1cd7) → AttackerContract (0xe408..794b)
  attack(uint256,uint256)  sel=0xe1fa7638  value=0 ETH

  [CALL] AttackerContract → BalancerVault (0xba12..f2c8)
    flashLoan(address,address[],uint256[],bytes)  sel=0x5c38449e  value=0 ETH
    # Borrows 51 WETH (0x2c3c465ca58ec0000 = 51e18)

    [CALL] BalancerVault → WETH (0xc02a..6cc2)
      transfer(address,uint256)  sel=0xa9059cbb  value=0 ETH
      # Transfers 51 WETH to AttackerContract

    [CALL] BalancerVault → AttackerContract (0xe408..794b)
      receiveFlashLoan(address[],uint256[],uint256[],bytes)  sel=0xf04f2707

      [CALL] AttackerContract → WETH (0xc02a..6cc2)
        withdraw(uint256)  sel=0x2e1a7d4d  value=0 ETH
        # Unwraps 51 WETH → 51 ETH
        [CALL] WETH → AttackerContract  0x (ETH transfer)  value=51.0000 ETH

      [CALL] AttackerContract → AlkemiEarnPublic proxy (0x4822..a888)
        supply(address,uint256)  sel=0xf2b9fdb8  value=50.0000 ETH
        # Deposits 50 ETH as collateral (assetCollateral = AlkemiWETHGateway)
        [DELEGATECALL] Proxy → AlkemiEarnPublic impl (0x85a9..3df7)
          supply(address,uint256)  sel=0xf2b9fdb8  value=50.0000 ETH
          [CALL] AlkemiEarnPublic → AlkemiWETHGateway (0x8125..40ab)
            deposit()  sel=0xd0e30db0  value=50.0000 ETH
          # refreshAlkSupplyIndex × 10 (ALK reward index updates — not exploited)

      [CALL] AttackerContract → AlkemiEarnPublic proxy (0x4822..a888)
        borrow(address,uint256)  sel=0x4b8a3529  value=0 ETH
        # Borrows ~39.36 ETH (exact amount from attack() arg)
        [DELEGATECALL] Proxy → AlkemiEarnPublic impl (0x85a9..3df7)
          borrow(address,uint256)  sel=0x4b8a3529  value=0 ETH
          [CALL] AlkemiEarnPublic → AlkemiWETHGateway (0x8125..40ab)
            withdraw(address,uint256)  sel=0xf3fef3a3  value=0 ETH
            [CALL] AlkemiWETHGateway → AttackerContract  0x  value=39.5000 ETH

      [STATICCALL] AttackerContract → AlkemiEarnPublic proxy (0x4822..a888)
        getBorrowBalance(address,address)  sel=0x118e31b7  value=0 ETH
        # Returns 39.5395 ETH (includes accrued interest)
        [DELEGATECALL] Proxy → impl  getBorrowBalance  → 39.5395 ETH

      [CALL] AttackerContract → AlkemiEarnPublic proxy (0x4822..a888)
        liquidateBorrow(address,address,address,uint256)  sel=0xe61604cf
        # targetAccount = 0xe408..794b (self), assetBorrow = WETH_GATEWAY,
        # assetCollateral = WETH_GATEWAY, requestedAmountClose = 39.5395 ETH
        # msg.value = 39.5395 ETH
        [DELEGATECALL] Proxy → AlkemiEarnPublic impl (0x85a9..3df7)
          liquidateBorrow(...)  value=39.5395 ETH
          # Storage alias triggers: supply balance slot inflated to 93.49 ETH
          [CALL] AlkemiEarnPublic → AlkemiWETHGateway (0x8125..40ab)
            deposit()  sel=0xd0e30db0  value=39.5395 ETH
          # refreshAlkSupplyIndex/BorrowIndex calls (ALK accounting only)

      [CALL] AttackerContract → AlkemiEarnPublic proxy (0x4822..a888)
        withdraw(address,uint256)  sel=0xf3fef3a3  value=0 ETH
        # Withdraws max (uint256.max) — protocol pays out 93.49345 ETH
        [DELEGATECALL] Proxy → AlkemiEarnPublic impl (0x85a9..3df7)
          withdraw(address,uint256)
          [CALL] AlkemiEarnPublic → AlkemiWETHGateway (0x8125..40ab)
            withdraw(address,uint256)  sel=0xf3fef3a3
            [CALL] AlkemiWETHGateway → AttackerContract  0x  value=93.4934 ETH

      [CALL] AttackerContract → WETH (0xc02a..6cc2)
        deposit()  sel=0xd0e30db0  value=51.0000 ETH
        # Wraps 51 ETH → 51 WETH for flash loan repayment

      [CALL] AttackerContract → WETH (0xc02a..6cc2)
        transfer(address,uint256)  sel=0xa9059cbb  value=0 ETH
        # Transfers 51 WETH to BalancerVault (flash loan repayment, 0 fee)

      [CALL] AttackerContract → AttackerEOA (0x0ed1..1cd7)
        0x  value=43.4539 ETH
        # Profit sent to EOA

Financial Impact

Source: funds_flow.json (primary evidence).

FlowAmountDirection
Flash loan received (WETH)51.0000 WETHBalancer → Attacker
ETH supply (collateral)50.0000 ETHAttacker → AlkemiEarnPublic
ETH borrow proceeds39.5000 ETHAlkemiEarnPublic → Attacker
ETH liquidation repay39.5395 ETHAttacker → AlkemiEarnPublic
ETH withdrawal (inflated)93.4934 ETHAlkemiEarnPublic → Attacker
Flash loan repaid (WETH)51.0000 WETHAttacker → Balancer
Net ETH profit to EOA43.45395 ETHAttacker → AttackerEOA

Attacker EOA balance change: from 0.0993 ETH to 43.5522 ETH — net gain of 43.4529 ETH (gas costs account for the ~0.001 ETH difference from the attacker_gains figure).

At approximately $2,500/ETH (ETH price around March 10, 2026), the attacker gained roughly $108,600. The AlkemiEarnPublic protocol’s ETH reserves were drained by the net surplus: the protocol paid out 93.49 ETH but only received 50 ETH (supply) + 39.54 ETH (liquidation repay) = 89.54 ETH in — a net drain of ~43.49 ETH from protocol liquidity providers.

The protocol’s ETH market is effectively drained of ~43.5 ETH that was never legitimately earned; LP depositors absorb this loss. Other token markets (USDC, WBTC, DAI) are unaffected.

Evidence

Transaction: 0xa17001eb39f867b8bed850de9107018a2d2503f95f15e4dceb7d68fff5ef6d9d Block: 24,626,979 — timestamp 2026-03-10 12:10:11 UTC Receipt status: 0x1 (success)

Selector verification (via cast sig):

  • liquidateBorrow(address,address,address,uint256)0xe61604cf
  • supply(address,uint256)0xf2b9fdb8
  • borrow(address,uint256)0x4b8a3529
  • withdraw(address,uint256)0xf3fef3a3
  • getBorrowBalance(address,address)0x118e31b7

Self-liquidation confirmed by trace: liquidateBorrow caller 0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b == targetAccount parameter 0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b (verified from decoded calldata).

assetBorrow == assetCollateral: both parameters equal 0x8125afd067094cd573255f82795339b9fe2a40ab (AlkemiWETHGateway), confirmed in calldata.

Storage aliasing arithmetic:

  • closeBorrowAmount = 39.5395 ETH (from trace and getBorrowBalance return)
  • seizeSupplyAmount = 39.5395 × 1.1 (liquidation discount 10%) = 43.49345 ETH
  • Expected final supply (write 2 overwrites write 1): 50 + 43.49345 = 93.49345 ETH
  • Actual withdrawn: 93.49345 ETH (match within 0.00005 ETH rounding) ✓

Key Transfer events (log indices from receipt):

  • Log 8: WETH Transfer BalancerVault → AttackerContract, 51 WETH (flash loan)
  • Log 18: AlkemiWETH minted to AlkemiEarnPublic, 50 ETH (supply)
  • Log 28: AlkemiWETH burned from AlkemiEarnPublic, 39.5 ETH (borrow)
  • Log 55: AlkemiWETH minted to AlkemiEarnPublic, 39.5395 ETH (liquidation repay)
  • Log 75: AlkemiWETH burned from AlkemiEarnPublic, 93.49345 ETH (withdraw — inflated)
  • Log 78: WETH Transfer AttackerContract → BalancerVault, 51 WETH (flash loan repayment)