Calculated from recorded token losses using historical USD prices at the incident time.
0x873f7c77d5489c1990f701e9bb312c103c5ebcdcf0a472db726730814bfd55f30x91383a15c391c142b80045d8b4730c1c37ac0378Ethereum0x694f8f9e0ec188f528d6354fdd0e47dca79b6f2cEthereumOn Ethereum mainnet block 15310017, transaction 0x873f7c77d5489c1990f701e9bb312c103c5ebcdcf0a472db726730814bfd55f3 executed a single-transaction ACT exploit against XStable2. The attacker EOA 0x334f3606886456537d0eb616497e770cbd2fbe5d invoked exploit contract 0x4fbb8840d37a21e38c8e438db27aae7bb91af052, flash-borrowed 77.993117814703407740 WETH from the Uniswap V2 USDT/WETH pair 0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852, manipulated the XST/WETH supported pool 0x694f8f9e0ec188f528d6354fdd0e47dca79b6f2c, repeatedly triggered XStable2 buy logic through skim(pair) self-transfers, harvested inflated XST, sold it back for WETH, repaid the flash swap, and exited with 27.077723156842114756 ETH of sender-side profit. The victim side lost 27.130181656842114756 WETH-equivalent liquidity according to the collected balance-diff artifact.
The root cause is an accounting flaw in XStable2. When the supported pool sends XST, XStable2 classifies the transfer as a buy unless LP supply fell. That classification also applies to pair.skim(pair) self-transfers. Each such self-transfer enters _implementBuy(), which increments _totalSupply without incrementing _largeTotal, lowers the global factor, and inflates the pool’s displayed XST balance. Because XStable2 refreshes supported-pool counters from mutable ERC-20 balances rather than Uniswap reserves, the attacker turned this inflation into real XST excess and then into redeemable WETH.
XStable2 is a rebasing-style token whose visible balances are derived from hidden _largeBalances and a global factor. In the verified getter code, balanceOf(account) divides each account’s large balance by getFactor(), and getFactor() returns _largeTotal / _totalSupply once presale is complete. That means any code path that raises _totalSupply while leaving _largeTotal unchanged will increase every holder’s displayed balance, including the balance seen by an external AMM pool.
function getFactor() public view returns(uint256) {
if (_presaleDone) {
return _largeTotal.div(_totalSupply);
} else {
return _largeTotal.div(Constants.getLaunchSupply());
}
}
function getUpdatedPoolCounters(address pool, address pairToken) public view returns (uint256, uint256, uint256) {
uint256 lpBalance = IERC20(pool).totalSupply();
uint256 tokenBalance = IERC20(address(this)).balanceOf(pool);
uint256 pairTokenBalance = IERC20(address(pairToken)).balanceOf(pool);
return (tokenBalance, pairTokenBalance, lpBalance);
}
The supported pool model is also critical. XStable2 tracks supported pools through _poolCounters, and syncPair() / silentSyncPair() refresh those counters from the raw token balances held by the pair contract, not from the pair’s stored reserves. A direct WETH donation into the pair therefore changes the pairToken side that XStable2 uses for mint sizing before the Uniswap pair has updated its internal reserve snapshot.
On the AMM side, Uniswap V2 skim(address to) is a public maintenance function. It transfers token balances that exceed the pair’s stored reserves to the provided recipient. In a normal ERC-20, skim(pair) is an accounting no-op. In XStable2, the call becomes dangerous because the token treats the pair as a supported pool and routes the transfer into buy logic.
The vulnerability is a protocol-level accounting bug in XStable2’s supported-pool handling, not a flaw in Uniswap. The vulnerable path begins in _transfer(), which refreshes supported-pool counters and then calls _getTxType(sender, recipient, lpBurn). If the sender is a supported pool and LP supply did not decrease, _getTxType() returns buy type 1 regardless of whether the recipient is the attacker, a third party, or the pool itself. There is no exclusion for sender == recipient.
function _transfer(address sender, address recipient, uint256 amount) private pausable {
...
if (isSupportedPool(sender)) {
lpBurn = syncPair(sender);
} else if (isSupportedPool(recipient)){
silentSyncPair(recipient);
} else {
silentSyncPair(_mainPool);
}
txType = _getTxType(sender, recipient, lpBurn);
...
}
function _getTxType(address sender, address recipient, bool lpBurn) private returns(uint256) {
uint256 txType = 2;
if (isSupportedPool(sender)) {
if (lpBurn) {
txType = 3;
} else {
txType = 1;
}
} else if (sender == Constants.getRouterAdd()) {
txType = 3;
}
return txType;
}
Once the transfer is misclassified as a buy, _implementBuy() increases _totalSupply at line 174 but does not increase _largeTotal. That is the code-level breakpoint. The effect is global: displayed XST balances rise because the factor falls, even though no new economic value entered the system. Since the pool is itself an XST holder, the pair’s displayed XST balance becomes larger than its stored reserve. That excess can then be harvested with another public skim() call.
function _implementBuy(address sender, address recipient, uint256 amount, uint256 largeAmount, uint256 currentFactor) private {
(uint256 stabilizerMint, uint256 treasuryMint, uint256 totalMint, uint256 incentive) = getMintValue(sender, amount);
_largeBalances[sender] = _largeBalances[sender].sub(largeAmount);
_largeBalances[recipient] = _largeBalances[recipient].add(largeAmount);
_largeBalances[getStabilizer()] = _largeBalances[getStabilizer()].add(stabilizerMint.mul(currentFactor));
_largeBalances[Constants.getTreasuryAdd()] = _largeBalances[Constants.getTreasuryAdd()].add(treasuryMint.mul(currentFactor));
_totalSupply = _totalSupply.add(totalMint);
...
}
The violated invariant is straightforward: a supported-pool maintenance action such as skim() must not mint spendable supply or change holder spendable balances unless new value enters the system. XStable2 breaks that invariant by allowing a permissionless, sender-side pool self-transfer to pass through minting logic and by sourcing mint inputs from raw pair balances that an attacker can move immediately before the call.
The exploit path is fully visible in the seed trace and matches the victim code. First, the attacker flash-borrowed 77.993117814703407740 WETH from 0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852. The trace shows the WETH transfer to 0x4fbb8840d37a21e38c8e438db27aae7bb91af052 and entry into uniswapV2Call, confirming that the entire exploit was funded by public flash liquidity.
Second, inside the callback, the attacker transferred the borrowed WETH into the XST/WETH pair and then bought 403258.404188293 XST from the same pair. This matters because XStable2’s getUpdatedPoolCounters() measures pairToken balances from IERC20(address(pairToken)).balanceOf(pool). The WETH donation therefore increased the pair-side input that XStable2 later used to size buy-side minting.
Third, the attacker seeded the pool state with a small XST sell and then started the key loop: repeated skim(pair) calls on the XST/WETH pair. In the trace, each such call causes a pair-to-pair XST transfer followed by mint events from the zero address to the stabilizer and treasury. That pattern is direct evidence that skim(pair) self-transfers passed through _implementBuy().
UniswapV2Pair::skim(UniswapV2Pair: [0x694f8F9E0ec188f528d6354fdd0e47DcA79B6f2C])
emit Transfer(from: UniswapV2Pair, to: UniswapV2Pair, value: 92415393294009)
emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x16a17E12031Db06932cD3b2Eb7450112B7c91289, value: 32292017763275)
emit Transfer(from: 0x0000000000000000000000000000000000000000, to: 0x3363Defd7447f14b7f696c0843AA96516Bc04808, value: 32292017763275)
The same trace segment also shows the pool’s displayed XST balance rising from one skim iteration to the next. Because _totalSupply rises while _largeTotal does not, getFactor() falls, so the same stored large balance maps to a larger visible XST balance for the pair. That manufactured excess stays above the pair’s stored reserve and becomes claimable through another public skim().
Fourth, after enough self-skims, the attacker called skim(attacker) to harvest the accumulated XST excess from the pair into the exploit contract. The attacker then transferred the harvested XST back into the pair and sold it for WETH. The root-cause artifact’s sequence is consistent with the trace’s final sell stage and with the balance-diff output showing WETH leaving the victim side and the sender EOA ending with a net ETH gain.
The collected balance diff quantifies the result:
{
"address": "0x334f3606886456537d0eb616497e770cbd2fbe5d",
"before_wei": "4307494675471009034",
"after_wei": "31385217832313123790",
"delta_wei": "27077723156842114756"
}
And the victim-side WETH depletion is visible in the same artifact:
{
"address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"before_wei": "4163249231168547687819549",
"after_wei": "4163222100986890845704793",
"delta_wei": "-27130181656842114756"
}
The adversary flow is a single, deterministic ACT transaction.
0x334f3606886456537d0eb616497e770cbd2fbe5d called exploit contract 0x4fbb8840d37a21e38c8e438db27aae7bb91af052.0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 and received 77.993117814703407740 WETH.0x694f8f9e0ec188f528d6354fdd0e47dca79b6f2c, then bought XST from the same pool.skim(pair). Each call generated a self-transfer that XStable2 misclassified as a buy and that minted extra supply while lowering the global factor.skim(attacker) to harvest the manufactured XST excess.This flow used only public, permissionless components: Uniswap V2 pair operations, ERC-20 transfers, WETH withdraw, and a self-deployed callback contract. No privileged role, compromised key, or attacker-side historical artifact was required to realize the opportunity from the pre-state.
The direct measurable loss is the WETH drained from the XST/WETH supported pool. The collected balance diff shows a -27130181656842114756 wei change on the WETH side, which is 27.130181656842114756 WETH in raw 18-decimal units. The sender EOA realized 27.077723156842114756 ETH after the transaction. The small gap between those numbers is consistent with execution overhead and in-transaction repayment mechanics noted in the root-cause artifact.
The qualitative impact is broader than the immediate WETH loss. The exploit demonstrates that XStable2’s supported-pool accounting lets any unprivileged actor convert internal supply inflation into immediately extractable AMM value. It also shows that external AMM maintenance functions such as skim() become dangerous when token-side accounting treats pair-originated self-transfers as economic buys.
0x873f7c77d5489c1990f701e9bb312c103c5ebcdcf0a472db726730814bfd55f3.skim(pair) calls, mint events, and final exit._transfer(), _implementBuy(), and _getTxType() in XST2.sol.Getters2.sol, especially getFactor() and getUpdatedPoolCounters().Setters2.sol, especially syncPair() and silentSyncPair().