UpSwing sell-pressure accounting can be inflated with transfer-plus-skim loops
Exploit Transactions
0x4b3df6e9c68ae482c71a02832f7f599ff58ff877ec05fed0abd95b31d2d7d912Victim Addresses
0x35a254223960c18b69c0526c46b013d022e93902Ethereum0x0e823a8569cf12c1e7c216d3b8aef64a7fc5fb34EthereumLoss Breakdown
Similar Incidents
QUATERNION Pair-Rebase Accounting Drift Enables Permissionless Drain
32%TomInu Reflection Pair Inflation Flashloan Exploit
30%NFTX Doodles collateral accounting flaw enables flash-loan ETH extraction
30%Euler DAI Reserve Donation
30%Pool16 lend/redeem accounting bug drains USDC without HOME backing
29%NOON Pool Drain via Public transfer
29%Root Cause Analysis
UpSwing sell-pressure accounting can be inflated with transfer-plus-skim loops
1. Incident Overview TL;DR
On Ethereum mainnet block 16433821, the attacker cluster used Euler's public WETH flash-loan wrapper to borrow 10 WETH, bought 199388836791259039979218 UPS from the UPS/WETH Uniswap V2 pair, recycled that same UPS through 70 transfer(pair) plus skim(attacker) loops, and then triggered UpSwing's hidden zero-value self-transfer path. That path called releasePressure, burned 74026315355928919806556 UPS from the live UPS/WETH pool, minted the same amount of STEAM to the attacker helper contract, and resynchronized the pair at a much smaller UPS reserve. The attacker then sold the recovered UPS back into the depleted pool for 1369840472125783023 WETH, repaid the 10 WETH flash loan, and kept 0.369840472125783023 WETH gross profit. After subtracting 0.050297881208822614 ETH of gas paid by the submitting EOA, the adversary cluster still realized a positive net ETH delta of 0.319542590916960409.
The root cause is a contract bug in UpSwing. UpSwing::_transfer treats every transfer whose recipient is the configured Uniswap pair as permanent sell pressure even when the pair immediately returns the tokens with skim. releasePressure later trusts that synthetic pressure as if it represented real net inventory added to the pool, burns real UPS from the pair, and mints STEAM to the attacker.
2. Key Background
UpSwing stores a configurable Uniswap V2 pair address in UNIv2 and uses that address as a special accounting sink. Any UPS transfer whose recipient equals UNIv2 increments txCount[sender] and adds a decayed amount to sellPressure[sender]. A second hidden branch treats sender == recipient && amount == 0 as a trigger to execute releasePressure(sender).
The UPS/WETH pool at 0x0e823a8569cf12c1e7c216d3b8aef64a7fc5fb34 is not a custom attacker component. The auditor's Etherscan metadata confirms that it is a verified UniswapV2Pair, so the public skim(address) function is expected behavior of a standard pair contract. That matters because skim transfers any token balance above the pair's stored reserves without updating reserves, which lets the attacker send UPS into the pair, have UpSwing count the transfer as sell pressure, and then immediately reclaim the same UPS.
STEAM is UpSwing's companion token. When releasePressure decides that an address has accumulated releasable pressure, it burns UPS from the pair, stages the same amount in steamToGenerate, and calls Steam::generateSteam so the attacker receives freshly minted STEAM. The exploit therefore couples two semantic effects: destroying real UPS inventory in the pair and minting equal STEAM to the attacker.
3. Vulnerability Analysis & Root Cause Summary
The vulnerable logic sits entirely inside UpSwing. The contract assumes that a transfer into the Uniswap pair is equivalent to a completed sell, but that assumption is false because Uniswap V2 exposes skim(address) and does not update reserves when excess token balances are swept back out. As a result, the same UPS inventory can be recycled through the pair over and over while sellPressure keeps increasing. The attacker does not need privileged access, a victim signature, or any non-public input: a public flash loan, standard router calls, public skim, and ordinary ERC-20 transfers are sufficient. The decisive breakpoint is the recipient == UNIv2 branch in _transfer, where transient pair transfers are counted as pressure before any check that the pair actually retained the UPS. The monetization breakpoint is releasePressure, which converts that synthetic pressure into a real pool burn and a matching STEAM mint. This is therefore an ACT attack caused by broken tokenomics accounting, not by a helper-contract permission issue or by a flaw in Euler or Uniswap.
4. Detailed Root Cause Analysis
The critical victim-side code is the combination of UpSwing::_transfer, UpSwing::releasePressure, and Steam::generateSteam:
function releasePressure(address _address) internal {
uint256 amount = myPressure(_address);
if (amount < balanceOf(UNIv2)) {
sellPressure[_address] = 0;
addToSteam(_address, amount);
ERC20._burn(UNIv2, amount);
emit BurnedFromLiquidityPool(_address, amount);
_generateSteamFromUPSBurn(_address);
emit SteamGenerated(_address, amount);
txCount[_address] = 0;
} else if (amount > 0) {
sellPressure[_address] = sellPressure[_address].div(2);
}
IUNIv2(UNIv2).sync();
}
function _transfer(address sender, address recipient, uint256 amount) internal override {
ERC20._balances[sender] = ERC20._balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
ERC20._balances[recipient] = ERC20._balances[recipient].add(amount);
if (recipient == UNIv2) {
txCount[sender] = txCount[sender] + 1;
amount = amount.mul(UPSMath(txCount[sender])).div(1e10);
sellPressure[sender] = sellPressure[sender].add(amount);
}
if (sender == recipient && amount == 0) {
releasePressure(sender);
}
}
function generateSteam(address account, uint256 amount) external onlyUPS {
require((_totalSupply + amount) < _maxSupply, "STEAM token: cannot generate more steam than the max supply");
ERC20._mint(account, amount);
_steamMinted = _steamMinted.add(amount);
}
The violated invariant is straightforward: a user's accumulated sell pressure must correspond to net UPS that actually remains in the pair, because releasePressure later burns real pair inventory and mints real STEAM based on that accounting. UpSwing breaks that invariant by using the nominal transfer into the pair as the accounting source of truth.
The seed trace shows exactly how the attacker exploits that mismatch. First, the helper contract receives the flash-loaned 10 WETH, then spends 1 WETH through swapExactTokensForTokens to buy 199388836791259039979218 UPS from the UPS/WETH pool. Next, the helper repeatedly transfers that UPS to the pair and immediately calls UniswapV2Pair::skim(helper). During each transfer, UpSwing increments txCount and adds a decayed amount to sellPressure; during each skim, the attacker recovers the full UPS balance while the pair's stored reserves remain unchanged. After 70 loops, the trace shows UpSwing::myPressure(attacker) returning 74026315355928919806556.
The zero-value self-transfer then converts fake pressure into real state changes:
0x62e28f054efc24b26A794F5C1249B6349454352C::flashLoan(10000000000000000000, 0x1234)
...
UniswapV2Pair::skim(0x762d2A9f065304D42289f3f13Cc8EA23226d3b8C)
...
UpSwing::myPressure(0x762d2A9f065304D42289f3f13Cc8EA23226d3b8C) -> 74026315355928919806556
UpSwing::transfer(0x762d2A9f065304D42289f3f13Cc8EA23226d3b8C, 0)
emit BurnedFromLiquidityPool(..., 74026315355928919806556)
0xC67a3b1587B2421728750294f0A049E98Eb0DA65::generateSteam(..., 74026315355928919806556)
emit SteamGenerated(..., 74026315355928919806556)
UniswapV2Pair::sync()
That trace matches the observed balance deltas. The collector's balance diff shows the pair lost exactly 74026315355928919806556 UPS, while the attacker helper gained exactly 74026315355928919806556 STEAM. Once the pair is resynchronized with only 164936558924830135236 UPS left against 1370977028350625716 WETH, the attacker sells the same UPS inventory back into the now-depleted pool and extracts 1369840472125783023 WETH. The flash loan only improves capital efficiency; the exploit works because UpSwing confuses transient pair transfers with real economic sells.
5. Adversary Flow Analysis
The adversary flow is a single attacker-crafted transaction, 0x4b3df6e9c68ae482c71a02832f7f599ff58ff877ec05fed0abd95b31d2d7d912, sent by EOA 0xceed34f03a3e607cc04c2d0441c7386b190d7cf4 to helper contract 0x762d2a9f065304d42289f3f13cc8ea23226d3b8c.
- The helper resolves Euler's WETH dToken and calls the public flash-loan wrapper for
10 WETH. - Inside the flash-loan callback, the helper swaps
1 WETHfor UPS through Uniswap V2 Router02. - The helper loops
70times overUPS.transfer(UPS_WETH_PAIR, recycledUps)followed byUPS_WETH_PAIR.skim(address(this)), which increasessellPressurewhile preserving the same UPS inventory under attacker control. - The helper calls
UPS.transfer(address(this), 0), which hits UpSwing's hidden zero-value self-transfer branch and executesreleasePressure. releasePressureburns74026315355928919806556UPS from the pair, mints matching STEAM to the helper, and syncs the pair.- The helper swaps its UPS back to WETH through the now-depleted pool, repays Euler's
10 WETH, and retains the residual WETH profit.
This flow is permissionless from end to end. Every primitive used in the transaction is publicly callable: Euler flash loans, Uniswap router swaps, Uniswap pair skim, UPS transfers, and the zero-value self-transfer trigger.
6. Impact & Losses
The measurable loss is concentrated in the UPS/WETH pool and in UpSwing's token supplies:
- The UPS/WETH pair lost
74026315355928919806556UPS through an artificial burn. - The UPS/WETH pair lost
369840472125783023WETH to the attacker's exit swap. - The attacker helper contract received
74026315355928919806556newly minted STEAM without providing equivalent economic value.
From the adversary perspective, the helper contract finished with 0.369840472125783023 WETH after repaying the flash loan, while the submitting EOA paid 0.050297881208822614 ETH in gas. The cluster therefore still realized a positive net ETH delta of 0.319542590916960409, even before placing any value on the minted STEAM.
7. References
- Seed exploit transaction:
0x4b3df6e9c68ae482c71a02832f7f599ff58ff877ec05fed0abd95b31d2d7d912 - UpSwing token contract:
0x35a254223960c18b69c0526c46b013d022e93902 - STEAM token contract:
0xc67a3b1587b2421728750294f0a049e98eb0da65 - UPS/WETH Uniswap V2 pair:
0x0e823a8569cf12c1e7c216d3b8aef64a7fc5fb34 - Verified UpSwing source:
/workspace/session/artifacts/collector/seed/1/0x35a254223960c18b69c0526c46b013d022e93902/src/UpSwing.sol - Verified STEAM source:
/workspace/session/artifacts/collector/seed/1/0xc67a3b1587b2421728750294f0a049e98eb0da65/src/Steam.sol - Seed transaction trace:
/workspace/session/artifacts/collector/seed/1/0x4b3df6e9c68ae482c71a02832f7f599ff58ff877ec05fed0abd95b31d2d7d912/trace.cast.log - Seed transaction balance diff:
/workspace/session/artifacts/collector/seed/1/0x4b3df6e9c68ae482c71a02832f7f599ff58ff877ec05fed0abd95b31d2d7d912/balance_diff.json - UPS/WETH pair verification metadata:
/workspace/session/artifacts/auditor/iter_1/pair_verification_etherscan.json