LiFi GasZipFacet / LibSwap arbitrary USDT transferFrom
Exploit Transactions
0xd82fe84e63b1aa52e1ce540582ee0895ba4a71ec5e7a632a3faa1aff3e763873Victim Addresses
0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eFEthereum0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaEEthereumLoss Breakdown
Similar Incidents
LiFi router allowance-drain exploit steals approved holder tokens
38%WBTC Drain via Insecure Router transferFrom Path
36%V3Utils Arbitrary Call Drain
35%Public SwapGuard envelope enabled arbitrary transferFrom drain of CoW Settlement DAI allowance
35%Spectra Router KYBERSWAP arbitrary call drains SdCrvCompounder
33%AnyswapV4Router WETH9 permit misuse drains WETH to ETH
31%Root Cause Analysis
LiFi GasZipFacet / LibSwap arbitrary USDT transferFrom
1. Incident Overview TL;DR
On Ethereum mainnet block 20318963, an adversary-controlled helper contract 0x986a… invoked LiFi’s GasZipFacet via the LiFiDiamond router 0x1231…, causing TetherToken (USDT) at 0xdAC17… to execute a transferFrom that drained 2,276,295.880553 USDT from EOA 0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eF to attacker EOA 0x8B3Cb6Bf982798fba233Bca56749e22EEc42DcF3 in a single transaction (tx 0xd82fe8…). The call path used depositToGasZipERC20 in GasZipFacet together with LibSwap.swap, which executed attacker-crafted call data under the LiFiDiamond’s authority as an approved USDT spender. Only 2 wei of native ETH were ultimately deposited into the GasZip router, leaving the victim with a large USDT loss and no compensating benefit.
The root cause is that GasZipFacet.depositToGasZipERC20 accepts arbitrary LibSwap.SwapData from the caller and passes it into LibSwap.swap, which performs a low-level call using the LiFiDiamond’s own allowances and balances. Because LiFiDiamond had a pre-existing MAX_UINT USDT allowance from 0xABE4…, an attacker could encode an arbitrary TetherToken.transferFrom(0xABE4…, 0x8B3C…, 2276295880553) inside the swap call data and force LiFiDiamond to execute it, even though the victim did not initiate the transaction.
2. Key Background
LiFiDiamond at 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE is a diamond-style router that delegates certain entrypoints, including depositToGasZipERC20, to facet contracts such as GasZipFacet deployed at 0xf28A352377663cA134bd27B582b1a9A4dad7e534. GasZipFacet in turn relies on the LibSwap and LibAsset libraries to execute swaps on behalf of users within the LiFiDiamond context.
The GasZip protocol’s router contract at 0x9e22ebec84c7e4c4bd6d4ae7ff6f4d436d6d8390 (GasZip) accepts native-token deposits via a simple deposit(uint256 chainId, address to) function. The implementation records aggregate balances controlled by the contract owner and does not maintain per-user escrowed balances; only privileged owner functions can withdraw accumulated funds.
USDT (TetherToken) at 0xdAC17F958D2ee523a2206206994597C13D831ec7 is implemented as a StandardToken-style ERC20 with an allowance mapping allowed[owner][spender] and a MAX_UINT sentinel. In StandardToken.transferFrom, when _allowance equals MAX_UINT, the allowance is not decremented, allowing effectively unlimited transfers by the approved spender until the allowance is changed. TetherToken wraps this behavior and forwards transferFrom to StandardToken when the contract is not deprecated. In this incident, 0xABE4… had previously granted LiFiDiamond 0x1231… a MAX_UINT allowance to spend its USDT.
LibSwap.swap is designed as a generic swap executor. It takes a SwapData struct with fields:
struct SwapData {
address callTo;
address approveTo;
address sendingAssetId;
address receivingAssetId;
uint256 fromAmount;
bytes callData;
bool requiresDeposit;
}
The function checks that callTo is a contract, approves fromAmount of sendingAssetId to approveTo if needed, and then performs a low-level call to callTo with the provided callData from the calling contract’s context. There is no restriction that callTo must be a DEX or that callData must represent a particular method; any external call encoded in callData will be executed with the router’s allowances and balances.
3. Vulnerability Analysis & Root Cause Summary
Functionally, GasZipFacet.depositToGasZipERC20 is intended to swap an ERC20 token held by LiFiDiamond into native ETH and then deposit the resulting ETH into GasZip for gas distribution. It does so by calling LibSwap.swap with a caller-provided SwapData struct, and then computing the native balance difference as the swap output to deposit.
The vulnerability arises because the facet treats the SwapData as a benign swap description while giving the caller full control over callTo, approveTo, sendingAssetId, receivingAssetId, fromAmount, and callData. LibSwap.swap executes the encoded call using the LiFiDiamond’s own allowances and balances, not the caller’s, and there is no binding between the encoded call and any particular user-initiated flow or invariant about who should lose or gain tokens.
Given that LiFiDiamond is a trusted router that many users approve for large token amounts (including MAX_UINT approvals), this design allows an attacker to:
- Read an allowance from a victim EOA to LiFiDiamond.
- Construct
SwapDatawherecallTois the victim’s token contract (USDT), andcallDataencodes atransferFrom(victim, attacker, amount)call. - Invoke
depositToGasZipERC20so that LiFiDiamond uses the victim’s allowance to execute the transfer on behalf of the attacker.
In this incident, that pattern was instantiated with USDT and a pre-existing MAX_UINT allowance, allowing the adversary to drain 2,276,295.880553 USDT from a single victim without their participation in the transaction.
4. Detailed Root Cause Analysis
The core invariant that should hold is:
For any user U, USDT held by U and pre-approved to LiFiDiamond for routing should only be moved when U is the initiating party in a swap/bridge flow that preserves U’s intended asset ownership; no adversary EOA should be able to unilaterally drain U’s approved USDT using the router.
The concrete breakpoint in the code path is the combination of GasZipFacet.depositToGasZipERC20 and LibSwap.swap:
function depositToGasZipERC20(
LibSwap.SwapData calldata _swapData,
uint256 _destinationChains,
address _recipient
) public {
uint256 currentNativeBalance = address(this).balance;
LibSwap.swap(0, _swapData);
uint256 swapOutputAmount = address(this).balance - currentNativeBalance;
gasZipRouter.deposit{ value: swapOutputAmount }(_destinationChains, _recipient);
}
LibSwap.swap is implemented (excerpt) as:
function swap(bytes32 transactionId, SwapData calldata _swap) internal {
if (!LibAsset.isContract(_swap.callTo)) revert InvalidContract();
uint256 fromAmount = _swap.fromAmount;
if (fromAmount == 0) revert NoSwapFromZeroBalance();
uint256 nativeValue = LibAsset.isNativeAsset(_swap.sendingAssetId)
? _swap.fromAmount
: 0;
uint256 initialReceivingAssetBalance = LibAsset.getOwnBalance(
_swap.receivingAssetId
);
if (nativeValue == 0) {
LibAsset.maxApproveERC20(
IERC20(_swap.sendingAssetId),
_swap.approveTo,
_swap.fromAmount
);
}
(bool success, bytes memory res) = _swap.callTo.call{ value: nativeValue }(
_swap.callData
);
if (!success) {
LibUtil.revertWith(res);
}
// ...
}
In the seed transaction 0xd82fe84e63b1aa52e1ce540582ee0895ba4a71ec5e7a632a3faa1aff3e763873 (chainid 1), captured in artifacts/root_cause/seed/1/0xd82fe8.../metadata.json and trace.cast.log, the following steps occur:
-
EOA 0x8B3C… sends a zero-ETH EIP-1559 transaction to helper contract 0x986aca5f2cA6b120F4361c519d7a49C5AC50C240 with selector
0x85b4a4fcand arguments including:router = 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE(LiFiDiamond),token = 0xF929bA2AEec16cFfcfc66858A9434E194BAaf80D,victim = 0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eF,usdt = 0xdAC17F958D2ee523a2206206994597C13D831ec7.
-
The helper contract first calls USDT’s
balanceOf(0xABE4…)andallowance(0xABE4…, 0x1231…). The trace showsallowancereturningMAX_UINT, consistent with the USDT StandardToken implementation:
uint public constant MAX_UINT = 2**256 - 1;
function transferFrom(address _from, address _to, uint _value) public onlyPayloadSize(3 * 32) {
var _allowance = allowed[_from][msg.sender];
// ...
if (_allowance < MAX_UINT) {
allowed[_from][msg.sender] = _allowance.sub(_value);
}
// balances[_from] -= _value; balances[_to] += _value; ...
}
Because the allowance equals MAX_UINT, USDT will not decrement the allowance when transferFrom is called by the approved spender.
-
The helper then invokes
LiFiDiamond.depositToGasZipERC20(routed via the diamond fallback to GasZipFacet) with a craftedLibSwap.SwapDatawhose fields, as reconstructed from the trace and analyzer summary, are:callTo = 0xdAC17F958D2ee523a2206206994597C13D831ec7(TetherToken),approveTo = 0x986aca5f2cA6b120F4361c519d7a49C5AC50C240(helper),sendingAssetId = 0xF929bA2AEec16cFfcfc66858A9434E194BAaf80D,receivingAssetId = 0xF929bA2AEec16cFfcfc66858A9434E194BAaf80D,fromAmount = 1,callData = ABI.encodeWithSignature("transferFrom(address,address,uint256)", 0xABE4…, 0x8B3C…, 2276295880553),requiresDeposit = true.
-
Inside GasZipFacet,
currentNativeBalance = address(this).balanceis recorded in the LiFiDiamond context.LibSwap.swapis then called, which:- Checks
callTois a contract (true for USDT). - Approves 1 unit of token 0xF929… to
approveTo(0x986a…), using LiFiDiamond’s token balance. - Performs a low-level call to USDT with the crafted
callDatafrom the LiFiDiamond context.
- Checks
-
In USDT, this results in a
transferFrom(0xABE4…, 0x8B3C…, 2276295880553)call wheremsg.senderis LiFiDiamond 0x1231…. Becauseallowed[0xABE4…][0x1231…] == MAX_UINT, the allowance is not reduced, but the balances change: 2,276,295.880553 USDT move from 0xABE4… to 0x8B3C…. The automated ERC20 balance delta extraction failed with a script error ("name 'os' is not defined"), but theTransferevent and storage modifications intrace.cast.logconfirm the movement. -
After
LibSwap.swapreturns, GasZipFacet computesswapOutputAmount = address(this).balance - currentNativeBalance. Thestate_diff.jsonfor the transaction shows:- GasZip 0x9e22… native balance increases by 2 wei.
- 0xF929… native balance decreases by 2 wei.
- Attacker EOA 0x8B3C… loses ~0.002589051846251937 ETH in gas fees and the DEX-like routing.
Thus,
swapOutputAmountis 2 wei, and GasZipFacet callsgasZipRouter.deposit{value: 2}(0, _recipient), where_recipientis controlled by the attacker (set to 0x986a… in this flow).
-
GasZip’s
depositimplementation simply checksmsg.value > 0, records the deposit in internal accounting owned by the protocol, and emits aDepositevent; it does not track any per-user obligation to repay the victim whose USDT funded the flow.
This sequence clearly violates the invariant: a victim EOA’s pre-approved USDT is moved to an attacker EOA solely because the attacker constructed a malicious SwapData and invoked depositToGasZipERC20. The attack relies only on standard ERC20 allowances and public contract ABIs, not on any privileged access.
5. Adversary Flow Analysis
The adversary strategy is a single-transaction anyone-can-take (ACT) opportunity on Ethereum mainnet. Under the ACT adversary model, any unprivileged EOA can deploy a helper contract and replicate this flow using only public data (on-chain storage and verified ABIs).
The adversary-related cluster consists of:
- EOA 0x8B3Cb6Bf982798fba233Bca56749e22EEc42DcF3: sender of the seed transaction and direct recipient of 2,276,295.880553 USDT.
- Contract 0x986aca5f2cA6b120F4361c519d7a49C5AC50C240: entry helper that orchestrates the pre-checks on USDT balances/allowances and calls into LiFiDiamond.
Victim-relevant accounts are:
- EOA 0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eF: USDT holder whose MAX_UINT approval to LiFiDiamond is exploited to fund the transferFrom.
- Contract 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE (LiFiDiamond): router that holds victim approvals and exposes the dangerous
depositToGasZipERC20entry.
End-to-end flow for tx 0xd82fe8…:
- 0x8B3C… submits the transaction to 0x986a… with parameters specifying LiFiDiamond, the intermediate token 0xF929…, the victim address, and the USDT contract.
- 0x986a… reads USDT’s
balanceOf(0xABE4…)andallowance(0xABE4…, 0x1231…)to verify sufficient funds and a MAX_UINT allowance. - 0x986a… calls
LiFiDiamond.depositToGasZipERC20with a tailoredLibSwap.SwapDatastruct that encodes the malicioustransferFromcall and a nominalfromAmountof 1. - Through the diamond fallback, LiFiDiamond delegatecalls into GasZipFacet, which:
- Records its native balance.
- Invokes
LibSwap.swap(0, _swapData).
LibSwap.swap:- Validates
callTo = USDTis a contract. - Approves 1 unit of token 0xF929… to 0x986a….
- Calls USDT’s
transferFrom(0xABE4…, 0x8B3C…, 2276295880553)using LiFiDiamond’s MAX_UINT allowance.
- Validates
- USDT moves 2,276,295.880553 USDT from 0xABE4… to 0x8B3C…, emitting a
Transferevent; the MAX_UINT allowance from 0xABE4… to 0x1231… is left unchanged. - GasZipFacet measures that LiFiDiamond’s native balance increased by 2 wei (via token 0xF929… and subsequent swaps), then calls
GasZip.deposit{value: 2}(0, 0x986a…), creating a tiny deposit entry benefiting the attacker’s helper. - The transaction finalizes with:
- Attacker 0x8B3C… holding +2,276,295.880553 USDT and paying ~0.0026 ETH in gas.
- Victim 0xABE4… holding -2,276,295.880553 USDT.
- GasZip routing only 2 wei of ETH.
Because all required data (USDT allowance/balance, LiFi ABIs, GasZipFacet/LibSwap code) are public, any unprivileged adversary observing the MAX_UINT allowance could have constructed the same transaction pattern using their own helper contract, satisfying the ACT feasibility requirements.
6. Impact & Losses
The measured impact within the seed transaction is:
- 2,276,295.880553 USDT transferred from 0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eF to 0x8B3Cb6Bf982798fba233Bca56749e22EEc42DcF3 via TetherToken’s
transferFromexecuted by LiFiDiamond. - No USDT leaves the attacker’s address in the same transaction; all loss in the USDT reference asset is borne by the victim.
- GasZip receives only 2 wei of native ETH as a deposit, and no compensating asset or service is provided to the victim within this transaction.
In the context of this root-cause analysis, the loss is therefore:
- Token: USDT (TetherToken at 0xdAC17F958D2ee523a2206206994597C13D831ec7)
- Amount: 2,276,295.880553 USDT
- Victim: EOA 0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eF
7. References
- Seed transaction:
- Chain: Ethereum mainnet (chainid 1)
- Tx hash: 0xd82fe84e63b1aa52e1ce540582ee0895ba4a71ec5e7a632a3faa1aff3e763873
- Evidence:
artifacts/root_cause/seed/1/0xd82fe8.../metadata.json,artifacts/root_cause/seed/1/0xd82fe8.../trace.cast.log
- Protocol contracts and libraries:
- LiFiDiamond router 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE (source:
artifacts/root_cause/data_collector/iter_1/contract/1/0x1231DEB6.../source/) - GasZipFacet and LibSwap/LibAsset at 0xf28A352377663cA134bd27B582b1a9A4dad7e534 (source:
artifacts/root_cause/data_collector/iter_1/contract/1/0xf28A3523.../source/) - GasZip router 0x9e22ebec84c7e4c4bd6d4ae7ff6f4d436d6d8390 (source:
artifacts/root_cause/data_collector/iter_1/contract/1/0x9e22.../source/src/GasZip.sol)
- LiFiDiamond router 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE (source:
- Token implementation:
- USDT (TetherToken) 0xdAC17F958D2ee523a2206206994597C13D831ec7 (source:
artifacts/root_cause/seed/1/0xdac17f95.../src/Contract.sol)
- USDT (TetherToken) 0xdAC17F958D2ee523a2206206994597C13D831ec7 (source:
- State and balances:
- Native balance diffs:
artifacts/root_cause/data_collector/iter_1/tx/1/0xd82fe8.../state_diff.json
- Native balance diffs: