Two weeks ago I had the opportunity to participate in Paradigm CTF 2022, a well known event in the Web3 security space. This was my first “live” CTF I had ever done and I was happy with how I went overall, but I could have done better when it came to familiarity with tools and time management. The difficulty of the challenges were definitely an eye opener into how much I still have to learn as a security researcher, there were many I missed that I plan to solve (and make writeups for) in the future.
One of the challenges that I solved was named “rescue”, where we have to recover 10 wrapped Ether had been accidentally sent to a newly deployed MasterChefHelper contract. This contract was made to easily exchange tokens for LP tokens for a given SushiSwap liquidity pool. You pass as arguments the pool you want to provide liquidity to, the token contract, amount of tokens that you want to exchange, as well as an optional minimum out of LP tokens. Upon calling the function swapTokenForPoolToken your input tokens are converted into the two tokens supported by the LP and then liquidity is provided, returning LP tokens to the caller.
If we look at _addLiquidity() we see that the amount of tokens to send to router.addLiquidity() is determined by the balance of each token rather than a fixed amount determined by the users input amountIn from swapTokenForPoolToken(). Since the input tokens are split and swapped equally between an LPs token0 and token1, if the LP were to contain WETH there would be an imbalance. Let’s say that 1WETH = 1,000USDT and we call _addLiquidity() to exchange 1WETH and 1,000USDT for LP tokens. Since their value is the same, all tokens are used and LP tokens are returned. In our case the value will not be the same, because the amount is retrieved from the balance. So if we wanted to exchange 1WETH and 1,000USDT for LP tokens, the balance check would cause the call to router.addLiquidity() to pass 11WETH and 1,000USDT. It would be 11WETH because the amount is determined by balanceOf() and since we accidentally sent 10WETH to this contract, we have the 1WETH plus the 10WETH.
The router.addLiquidity() function returns unused amounts, so the 10WETH would remain in the MasterChefHelper contract. If we are able to make the 10WETH used by router.addLiquidity(), they would be converted into LP tokens and sent to the caller which would be us. We can do this by also sending 10WETH worth of the other token in the pool. Let’s go back to our WETH and USDT example, where if we also sent 10WETH worth of USDT (10000) then it would be 11WETH and 11000USDT. The value of these amounts are equal so they will all be consumed and converted into LP tokens which are sent back to us.
To save any errors when developing the solution I assumed that my input token couldn’t be the same as any of the tokens in my target LP, so I chose to exchange USDC tokens for USDT-WETH LP tokens. I also made sure to transfer a little more than 10WETH worth of USDT to the MasterChefHelper contract because I was concerned that if I used the exact amount there may be a little amount of dust left, but the challenge requires the balance to be exactly zero. In summary here are the steps needed to complete the challenge:
Swap 10.1 WETH for USDT
Swap 1 WETH for USDC
Transfer all USDT (10.1 WETH worth) to the MasterChefHelper contract
Call swapTokenForPoolToken and provide all the USDC balance
During the call to router.addLiquidity() the 10.1WETH worth of USDT matches up with the 10WETH and it is converted into LP tokens which are sent to us