A manually reviewed external total is being used for this incident.
Source0xd82fe84e63b1aa52e1ce540582ee0895ba4a71ec5e7a632a3faa1aff3e7638730xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eFEthereum0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaEEthereumOn 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.
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.
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:
SwapData where callTo is the victim’s token contract (USDT), and callData encodes a transferFrom(victim, attacker, amount) call.depositToGasZipERC20 so 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.
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 0x85b4a4fc and arguments including:
router = 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE (LiFiDiamond),token = 0xF929bA2AEec16cFfcfc66858A9434E194BAaf80D,victim = 0xABE45eA636df7Ac90Fb7D8d8C74a081b169F92eF,usdt = 0xdAC17F958D2ee523a2206206994597C13D831ec7.The helper contract first calls USDT’s balanceOf(0xABE4…) and allowance(0xABE4…, 0x1231…). The trace shows allowance returning MAX_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 crafted LibSwap.SwapData whose 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).balance is recorded in the LiFiDiamond context. LibSwap.swap is then called, which:
callTo is a contract (true for USDT).approveTo (0x986a…), using LiFiDiamond’s token balance.callData from the LiFiDiamond context.In USDT, this results in a transferFrom(0xABE4…, 0x8B3C…, 2276295880553) call where msg.sender is LiFiDiamond 0x1231…. Because allowed[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 the Transfer event and storage modifications in trace.cast.log confirm the movement.
After LibSwap.swap returns, GasZipFacet computes swapOutputAmount = address(this).balance - currentNativeBalance. The state_diff.json for the transaction shows:
swapOutputAmount is 2 wei, and GasZipFacet calls gasZipRouter.deposit{value: 2}(0, _recipient), where _recipient is controlled by the attacker (set to 0x986a… in this flow).GasZip’s deposit implementation simply checks msg.value > 0, records the deposit in internal accounting owned by the protocol, and emits a Deposit event; 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.
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:
Victim-relevant accounts are:
depositToGasZipERC20 entry.End-to-end flow for tx 0xd82fe8…:
balanceOf(0xABE4…) and allowance(0xABE4…, 0x1231…) to verify sufficient funds and a MAX_UINT allowance.LiFiDiamond.depositToGasZipERC20 with a tailored LibSwap.SwapData struct that encodes the malicious transferFrom call and a nominal fromAmount of 1.LibSwap.swap(0, _swapData).LibSwap.swap:
callTo = USDT is a contract.transferFrom(0xABE4…, 0x8B3C…, 2276295880553) using LiFiDiamond’s MAX_UINT allowance.Transfer event; the MAX_UINT allowance from 0xABE4… to 0x1231… is left unchanged.GasZip.deposit{value: 2}(0, 0x986a…), creating a tiny deposit entry benefiting the attacker’s helper.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.
The measured impact within the seed transaction is:
transferFrom executed by LiFiDiamond.In the context of this root-cause analysis, the loss is therefore:
artifacts/root_cause/seed/1/0xd82fe8.../metadata.json, artifacts/root_cause/seed/1/0xd82fe8.../trace.cast.logartifacts/root_cause/data_collector/iter_1/contract/1/0x1231DEB6.../source/)artifacts/root_cause/data_collector/iter_1/contract/1/0xf28A3523.../source/)artifacts/root_cause/data_collector/iter_1/contract/1/0x9e22.../source/src/GasZip.sol)artifacts/root_cause/seed/1/0xdac17f95.../src/Contract.sol)artifacts/root_cause/data_collector/iter_1/tx/1/0xd82fe8.../state_diff.json