- Type: Exploit
- Network: Ethereum Mainnet
- Total lost: ~15MM USD ($7MM where returned afterwards)
- Category: Business Logic - Faulty Migration Process
- Vulnerable contracts:
- Tokens Lost
-
- 880 ETH
-
- 642,932 DAI
-
- 74,613,6575 CAW
-
- 11,837,577 TSUKA
- Attack transactions:
- Attacker Addresses:
-
- Deployer EOA: 0x161cebB807Ac181d5303A4cCec2FC580CC5899Fd
-
- Malicious Token: 0x2d4ABfDcD1385951DF4317f9F3463fB11b9A31DF
- Attack Block:: 15837165
- Date: Oct 27, 2022
- Reproduce:
forge test --match-contract Exploit_TeamFinance -vvv
- Get a Team Finance Lock NFT by providing malicious tokens
- Extend the lock duration period of each NFT
- Call
migrate()
using the Lock NFTs providing a migration target (V2 pair) - Swap and Transfer the loot to an external account
The process has two main parts: The Setup and The Attack.
The transactions performed on this part were made in order to bypass the initial checks of migrate()
- Deploy a malicious inflationary token
- Get Team Finance Lock NFTs:
- Providing ETH to pay the fees
- Setting the attacker's contract as the withdrawal address
- Backing the NFT with the malicious token
- Extend the duration of each NFT to sometime in the future
Now that the TeamFinance Lock migrate()
function is bypasseable by the attacker's contract and will also consider
the malicious tokens as additional liquidity provided.
- Call
migrate()
:- For each NFT, target different V2 Pairs
- On every migration use
sqrtPriceX96 = 79210883607084793911461085816
. This gets a price factor equal to 0.999563867 (*)
- Exchange the loot for stablecoins using Curve, when applies
- Send the loot to the external attacker's account
(*) Links and sources with more details on how this number is calculated, in the reproduction.
The main vulnerability being exploited is locking a custom token using the setup of the locking position to perform the migration from a Uniswap V2 pool to a V3. The attacker bypassed the migration controls by using protocol's NFT lock positions backed by thes malicious token.
function migrate(
uint256 _id,
IV3Migrator.MigrateParams calldata params,
bool noLiquidity,
uint160 sqrtPriceX96,
bool _mintNFT
)
external
payable
whenNotPaused
nonReentrant
{
...
Items memory lockedERC20 = lockedToken[_id];
require(block.timestamp < lockedERC20.unlockTime, "Unlock time already reached");
require(_msgSender() == lockedERC20.withdrawalAddress, "Unauthorised sender");
require(!lockedERC20.withdrawn, "Already withdrawn");
uint256 totalSupplyBeforeMigrate = nonfungiblePositionManager.totalSupply();
//scope for solving stack too deep error
{
uint256 ethBalanceBefore = address(this).balance;
uint256 token0BalanceBefore = IERC20(params.token0).balanceOf(address(this));
uint256 token1BalanceBefore = IERC20(params.token1).balanceOf(address(this));
//initialize the pool if not yet initialized
if(noLiquidity) {
v3Migrator.createAndInitializePoolIfNecessary(params.token0, params.token1, params.fee, sqrtPriceX96);
}
IERC20(params.pair).approve(address(v3Migrator), params.liquidityToMigrate);
v3Migrator.migrate(params);
//refund eth or tokens
uint256 refundEth = address(this).balance - ethBalanceBefore;
(bool refundSuccess,) = _msgSender().call.value(refundEth)("");
require(refundSuccess, 'Refund ETH failed');
uint256 token0BalanceAfter = IERC20(params.token0).balanceOf(address(this));
uint256 refundToken0 = token0BalanceAfter - token0BalanceBefore;
if( refundToken0 > 0 ) {
require(IERC20(params.token0).transfer(_msgSender(), refundToken0));
}
uint256 token1BalanceAfter = IERC20(params.token1).balanceOf(address(this));
uint256 refundToken1 = token1BalanceAfter - token1BalanceBefore;
if( refundToken1 > 0 ) {
require(IERC20(params.token1).transfer(_msgSender(), refundToken1));
}
}
...
emit LiquidityMigrated(_msgSender(), _id, newDepositId, tokenId);
}
Because the migrate()
function refunds the difference after the migration, the attacker abused from this feature by manipulating the price
of the tokens involved on each pool.
The steps of THE SETUP bypass the require
statements by:
- Calling migrate after the extended period
- Performing the migration from the attacker's contract
- Not withdrawing the locked position
Due to the weakness of those checks, the attacker now is able to bypass the migration access control and specify any custom parameters in this process.
The attacker provided the sqrtPriceX96
and also used the malicious tokens to inflate the price of each pool receiving outstanding refunds
draining the Lock contract via the migration process.
- The most general recomendation for cases like this one: beware of user input parameters.
- If the protocol allows users to provide arbitrary tokens to execute any type of logic, take into consideration that malicious tokens of any nature could be provided (hookable, custom implemenations, inflatable, etc.).
- It is a good practise also, to set reasonable boundaries for some input parameters (such as the square root price) even if a function is meant to be permissioned or called by specific users to mitigate any loss of access control (private key compromised, authentication bypass, etc).
- Carefully review and check migration processes as they will likely be called once most likely conveying token transfers of considerable amounts.