Predy Finance Attack - How a Liquidity Pool Can Be Drained
The PredyPool contract on Arbitrum, part of the Predy Finance project, was attacked on May 14, 2024. In this attack, the attacker exploited a vulnerability in the PredyPool contract to transfer the liquidity from existing pairs into a newly created pair, successfully draining the pool. Let's dive in to discover how this attack was carried out.
Overview
Attacker address:
https://arbiscan.io/address/0x76b02ab483482740248e2ab38b5a879a31c6d008
Attack transaction: https://arbiscan.io/tx/0xbe163f651d23f0c9e4d4a443c0cc163134a31a1c2761b60188adcfd33178f50f
Vulnerable contract (PredyPool): https://arbiscan.io/address/0x9215748657319b17fecb2b5d086a3147bfbc8613
Exploit analysis
The PredyPool
contract allows users to create their own pairs, which are registered on Uniswap V3. This verification process is conducted by calling the UniswapV3Factory
contract. By owning a pair, the creator can whitelist addresses permitted to trade
with it. This feature facilitates the opening or closing of perpetual future positions on the Predy platform. With the established pair, users can utilize the supply
function to add tokens as liquidity or withdraw them by calling the withdraw
function.
Before the attack, the PredyPool
had two pairs (Bridged-USDC, WETH) and (USDC, WETH), with only whitelisted addresses permitted to trade with these pairs. However, the PredyPool
contract does not prevent the creation of new pairs with the same tokens as the existing pairs. Therefore, to use the trade
feature, which could trigger the exploit, the attacker created their own new pair of (USDC, WETH).
Examining the source code of the trade function, we can see that it will make a call to callTradeAfterCallback
and subsequently trigger the predyTradeAfterCallback
hook to the attacker's contract. The code responsible for protecting the contract’s assets from being drained after the trade
call is PositionCalculator.checkSafe()
, located at the bottom of the trade
function.
In the callTradeAfterCallback
function, we can see a call to initializeLock()
, which sets the locker to the current caller.
Inside the callback hook, the caller can freely call the take
function, which is protected by the onlyByLocker
modifier, to borrow tokens from the pool.
Inside the callback hook, the attacker tried to move the liquidity from the existing (USDC, WETH) pair into his own new pair by making the following calls for each token:
take()
: Takes all the tokens from the pool.supply()
: Supplies the taken tokens as liquidity into his new pair.
We can notice that the supply function has the nonReentrant
modifier to protect the pool from reentrancy attacks. However, the trade
function does not have this modifier, so this reentrancy attack can be executed successfully. Finally, the attacker completed the exploit, moving all the liquidity of the (USDC, WETH) pair into his new pair. After the reentrancy attack and completion of the trade call, the attacker can drain all the assets of the pool by withdrawing the liquidity from his pair.
Current status
The drained funds of the PredyPool
contract amount to approximately 219,585 USDC and 83.9 WETH. After the attack occurred, the Predy team upgraded the contract to pause the pool temporarily.
Conclusion
From this attack, we can summarize some major vulnerabilities within the PredyPool
contract:
Improper use of
nonReentrant
modifiers, which failed to protect the contract from this incident.Sharing of the pair’s liquidity in a single contract, which can make all pairs vulnerable if a single pair is manipulated or attacked.
By sharing our thoughts on these past incidents, we hope that our web3 community can avoid these same attack vectors in future projects and make the DeFi ecosystem more secure.