Calculated from recorded token losses using historical USD prices at the incident time.
0x4822d9172e5b76b9db37b75f5552f9988f98a888Ethereum0x8125afd067094cd573255f82795339b9fe2a40abEthereumOn Ethereum mainnet block 24626979, an unprivileged adversary deployed helper contract 0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b and then executed transaction 0xa17001eb39f867b8bed850de9107018a2d2503f95f15e4dceb7d68fff5ef6d9d against Alkemi Earn Public proxy 0x4822d9172e5b76b9db37b75f5552f9988f98a888. The helper flash-borrowed 51 WETH from Balancer, supplied 50 ETH into the AlkemiWETH market, borrowed 39.5 ETH, liquidated its own still-solvent position, withdrew an inflated 93.49345 ETH-equivalent balance, repaid the flash loan, and left the controlling EOA 0x0ed1c01b8420a965d7bd2374db02896464c91cd7 with a net native-ETH gain of 43.452897898403494627 after gas.
The root cause is the composition of two protocol bugs in the verified implementation 0x85a948fd70b2b415bda93324581fb5fff1293df7. First, the supported-market liquidation cap ignores actual account shortfall and therefore allows liquidation of healthy positions. Second, the self-liquidation path assumes borrower and liquidator collateral slots are different accounts, so when targetAccount == msg.sender the function overwrites the same collateral slot twice and turns a collateral transfer into collateral inflation.
Alkemi Earn Public uses a proxy at 0x4822d9172e5b76b9db37b75f5552f9988f98a888 that delegates to implementation 0x85a948fd70b2b415bda93324581fb5fff1293df7. Its ETH market is the custom AlkemiWETH contract at 0x8125afd067094cd573255f82795339b9fe2a40ab. At block , the WETH market was configured with , , , and enough protocol cash to honor a large withdrawal: WETH before the exploit.
24626978collateralRatio = 1.25e18liquidationDiscount = 0.1e18closeFactorMantissa = 0.5e1844.171246686849020997The ETH path matters because Alkemi Earn Public internally tracks balances in AlkemiWETH and converts them back to ETH during borrow and withdraw. The collected AlkemiWETH source shows that the market wrapper directly unwraps and transfers ETH to the recipient:
function withdraw(address user, uint256 wad) public {
require(balanceOf[msg.sender] >= wad);
balanceOf[msg.sender] -= wad;
user.transfer(wad);
emit Withdrawal(msg.sender, wad);
}
That means any inflated internal AlkemiWETH balance becomes immediately withdrawable native ETH once the attacker clears the borrow.
The exploit is an ACT-style protocol attack, not an access-control issue or attacker-only artifact. The first bug lives in the liquidation eligibility path for supported markets: the code calculates the target account's shortfall but never uses that value when determining how much debt may be closed. Instead, it uses only close factor, borrow balance, and discounted price, so a solvent account can still be liquidated. The second bug lives in liquidateBorrow state updates: the function reads target and liquidator collateral balances independently and then writes them back independently, but it never guards against the borrower and liquidator being the same address in the same collateral market. When both storage references alias one slot, the second write replaces the first and leaves the account with currentSupply + seizedAmount instead of a no-op. Because the market is the ETH wrapper market, the attacker can immediately convert the inflated internal balance into ETH and drain protocol cash. The exploit therefore breaks two core invariants: healthy positions must not be liquidatable, and self-liquidation must not increase the liquidator's net collateral.
The verified implementation computes a shortfall value and then discards it. In the supported-market path below, accountShortfall_TargetUser is fetched but the return value depends only on close factor and borrow balance:
function calculateDiscountedRepayToEvenAmount(
address targetAccount,
Exp memory underwaterAssetPrice,
address assetBorrow
) internal view returns (Error, uint256) {
Error err;
Exp memory _accountLiquidity;
Exp memory accountShortfall_TargetUser;
...
(err, _accountLiquidity, accountShortfall_TargetUser) =
calculateAccountLiquidity(targetAccount);
...
uint256 borrowBalance = getBorrowBalance(targetAccount, assetBorrow);
Exp memory maxClose;
(err, maxClose) = mulScalar(
Exp({mantissa: closeFactorMantissa}),
borrowBalance
);
(err, rawResult) = divExp(maxClose, discountedPrice_UnderwaterAsset);
return (Error.NO_ERROR, truncate(rawResult));
}
For the exploit position, the helper supplied 50 ETH and borrowed 39.5 ETH, producing a borrow balance with fee of 39.5395 WETH and still leaving positive account liquidity. The decoded facts show account_liquidity_after_borrow = 0.575625 ETH, but the buggy formula still returned 131.798333333333333333 WETH-equivalent as the supported-market liquidation bound, so Alkemi accepted full liquidation of a healthy account.
The second defect appears when the borrower liquidates itself in the same market. liquidateBorrow loads target and liquidator collateral balances from storage, computes updated borrower and liquidator balances separately, and then writes both results back:
Balance storage supplyBalance_TargetCollateralAsset =
supplyBalances[targetAccount][assetCollateral];
Balance storage supplyBalance_LiquidatorCollateralAsset =
supplyBalances[localResults.liquidator][assetCollateral];
(err, localResults.updatedSupplyBalance_TargetCollateralAsset) = sub(
localResults.currentSupplyBalance_TargetCollateralAsset,
localResults.seizeSupplyAmount_TargetCollateralAsset
);
(err, localResults.updatedSupplyBalance_LiquidatorCollateralAsset) = add(
localResults.currentSupplyBalance_LiquidatorCollateralAsset,
localResults.seizeSupplyAmount_TargetCollateralAsset
);
supplyBalance_TargetCollateralAsset.principal =
localResults.updatedSupplyBalance_TargetCollateralAsset;
supplyBalance_LiquidatorCollateralAsset.principal =
localResults.updatedSupplyBalance_LiquidatorCollateralAsset;
When targetAccount == msg.sender and assetCollateral == assetBorrow == AlkemiWETH, both Balance storage variables point to the same storage slot. Both currentSupplyBalance_* values are calculated from the same pre-liquidation principal, so the first write stores current - seize and the second write immediately overwrites that slot with current + seize. Instead of being collateral-neutral, the self-liquidation mints extra collateral into the attacker's own supply balance.
The seed trace shows the exploit sequence exactly. The human-readable trace fragment below is from the exploit transaction:
emit SupplyReceived(... amount: 50000000000000000000, newBalance: 50000000000000000000)
emit BorrowTaken(... amount: 39500000000000000000, borrowAmountWithFee: 39539500000000000000)
emit BorrowLiquidated(
targetAccount: 0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b,
liquidator: 0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b,
amountRepaid: 39539500000000000000,
amountSeized: 43493450000000000000
)
emit SupplyWithdrawn(... amount: 93493450000000000000, newBalance: 0)
Those events match the decoded exploit math. A 50 WETH starting supply becomes 93.49345 WETH after self-liquidation, which means the helper can withdraw 43.49345 WETH more than its legitimate collateral. Because AlkemiWETH unwraps directly to ETH and protocol cash was available, the attacker converted the accounting inflation into real ETH outflow from the protocol.
The adversary flow is short and fully permissionless.
0xcb59da3af2d08e7823e923bd2d3f048c4dce1ed65e6e56c5ad4969b3c2700fe1 deploys helper contract 0xe408b52aefb27a2fb4f1cd760a76daa4bf23794b.0xa17001eb39f867b8bed850de9107018a2d2503f95f15e4dceb7d68fff5ef6d9d calls the helper, which takes a public Balancer flash loan of 51 WETH.liquidateBorrow(targetAccount = self, assetBorrow = AlkemiWETH, assetCollateral = AlkemiWETH, requestedAmountClose = debt). The shortfall-bypass bug allows the liquidation, and the storage-alias bug inflates the helper's supply balance.93.49345 ETH-equivalent from Alkemi, repays the Balancer flash loan, and forwards the remaining ETH value back to the controlling EOA.The seed balance diff corroborates the outcome: protocol WETH cash fell from 44.171246686849020997 to 0.717296686849020997, while the attacker EOA's native balance increased by 43.452897898403494627 ETH after gas. No privileged keys, private calldata, or non-public state were required; any EOA could have deployed an equivalent helper and sent the same exploit transaction sequence.
The measurable protocol loss is 43.45395 WETH, encoded on-chain as 43453950000000000000 wei of WETH. This drained almost the entire WETH cash buffer from Alkemi Earn Public in the exploit block, leaving only 0.717296686849020997 WETH in the protocol immediately afterward.
The direct victim is Alkemi Earn Public, with the AlkemiWETH market as the specific drained component. The adversary realized a net profit of 43.452897898403494627 ETH after gas, and the profit arrived at EOA 0x0ed1c01b8420a965d7bd2374db02896464c91cd7. Because the exploit works on a public market using public flash liquidity, the loss should be classified as an ACT protocol attack.
0xa17001eb39f867b8bed850de9107018a2d2503f95f15e4dceb7d68fff5ef6d9d0xcb59da3af2d08e7823e923bd2d3f048c4dce1ed65e6e56c5ad4969b3c2700fe10x4822d9172e5b76b9db37b75f5552f9988f98a8880x85a948fd70b2b415bda93324581fb5fff1293df70x8125afd067094cd573255f82795339b9fe2a40abartifacts/collector/seed/1/0xa17001eb39f867b8bed850de9107018a2d2503f95f15e4dceb7d68fff5ef6d9d/artifacts/auditor/iter_0/supporting_facts.json0x85a948fd70b2b415bda93324581fb5fff1293df7