A-MAZE-X CTF Walkthrough | Part III
This post belongs to a series of articles dedicated to solving the DeFi challenges hosted by Secureum at Stanford University on the past 26 of August.
Additionally, this series is part of a greater series called A journey into smart contract security.
I think I'm getting used to doing this! Are you ready for the third one?
Table of contents
0. Table of contents
1. Challenge2.DEX — it’s always sunny in decentralized exchanges
1.1. Challenge description
1.2. First part
1.3. Second part
1.4. Third part
1.5. Fourth part
2. Vulnerability
2.1. A safer removeLiquidity.
3. The exploit
4. Solution
4.1. Exploit contract
4.2. Deployment
Challenge2.DEX — it’s always sunny in decentralized exchanges
Aaand again, before jumping to the challenge description, let’s do some thinking first.
Reminder to pause, and really think every time you see this emoji near a paragraph: 💭
— What can you imply from looking at the challenge's filename?
— That it is going to be related to a DEX?
— Yes! For those who don't know what it means, it stands for Decentralized Exchange. Basically, a smart contract that handles trades between two or more tokens for example.
— But what does the title mean? "It's always sunny in decentralized exchanges"?
— You beat me on that one, I know there was a TV show called "It's always sunny in Philadelphia" (that would probably get canceled today), but nothing more.
Further reading: What is a decentralized exchange?
To my understanding, one of the most challenging aspects of an exchange is the logic behind the trade ratio between two different assets. There are different approaches on this, but we’ll leave it here for now — one step at a time.
Challenge description
I bet you are familiar with decentralized exchanges: a magical place where one can exchange different tokens.
InsecureDexLP
is exactly that: a very insecure Uniswap-kind-of decentralized exchange.
Recently, the $ISEC token got listed in this dex and can be traded against a not-so-popular token called $SET.📌 Upon deployment, the
InSecureumToken
andSimpleERC223Token
contracts mint an initial supply of tokens 10 $ISEC and 10 $SET to the contract deployer.📌 The
InsecureDexLP
operates with $ISEC and $SET.📌 The dex has initial liquidity of 9 $ISEC and 9 $SET, provided by the contract deployer. This quantity can be increased by anyone through token deposits.
📌 Adding liquidity to the dex rewards liquidity pool tokens (LP tokens), which can be redeemed at any moment for the original funds.
Will you be able to drain most of
InsecureDexLP
's $ISEC/$SET liquidity?
Ok, so basically this DEX handles two tokens. In total there are minted 10 of each, 9 $ISEC/$SET to the DEX and 1 $ISEC/$SET airdropped to us.
What kind of logic vulnerabilities would you imagine possible in this domain? Write them down! 💭
Mine were a few 😅
- Price manipulation by external feed.
- Arithmetical mistakes.
- Some kind of error abusing the interchange of tokens?
- Tampering with token contracts states from outside the DEX.
Again, let’s split the code parts.
First part
Let's look at the imports, and state variables definitions.
From looking exclusively at the variables we can assume several things:
- The DEX only exchanges two assets, which will be called
token0
andtoken1
, both ERC20 compliant. The balance for each token from this contract address will be stored in variablesreserve0
andreserve1
respectively. totalSupply
and the reserves should be somehow updated every time there is an exchange.
Excellent, there it is. Let’s see how and when it is being used then.
— Is there a possibility that the balances for these tokens can change in an unexpected way or moment, generating some in-between chaos? 💭
Second part
Adding liquidity: shut up and take my money!
This is the first time we see the term liquidity in this series. I leave you with a simple definition and a recommended reading — or at least a quick glance, come on — before continuing.
— Ok, back to it.
It starts by transferring ownership of the tokens to the DEX itself. It then updates the current liquidity by using two simple mathematical formulas.
It updates the reserves and adds the recently calculated liquidity to totalSupply
and to the balances of msg.sender.
Despite being unchecked, the possibility of having an underflow or overflow would require huge numbers. I will be ignoring this for now.
In case you are wondering, I did check the math that is used to calculate the liquidity. I’ve found it’s pretty common. So I decided to leave it aside for now as well.
Third part
Removing liquidity: I want it back!
This expects the amount of liquidity to be removed to be equal to or lower than the actual balance from the msg.sender
. It then calculates the amount to be withdrawn for each token and transfers them to the sender.
The last step of it updates the liquidity balance for msg.sender
and the total liquidity in the dex.
If we are able to subtract more balance than we have, we can cause an underflow. Our liquidity will go through the roof.
unchecked {
_balances[msg.sender] -= amount;
totalSupply -= amount;
}
But this require is protecting that from happening 😢.
require(_balances[msg.sender] >= amount);
Fourth part
Swapping assets and the logic underneath.
The use of require
makes the swapping between assets limited to trading only between the two known tokens to the DEX.
The only thing that is not restricted is swapping from and to the same token. Not that it would make sense but we could if we want to! 😜
So, what's behind the function that calculates how much you can get from a swap?
According to the comments in the code, this part has been taken from the actual code from UniSwapV2Library. I corroborated they are similar in essence, and assumed it should be safe as well.
But I couldn't stop thinking about ways to get more tokens than I should:
1. How do I do to get the output of _calcAmountsOut
higher? Can I decrease the denominator or increase the numerator?
2. How do I increase the number of reserves I want to retrieve?
3. How do I decrease the number of reserves I want to provide as input?
Here I started to use bpython — a fancy curses interface to the Python interactive interpreter that I always use as an auxiliary tool— . I manually tried different scenarios with what I had in control. I even thought of abusing on the approve function the ISEC token has, to see where it leads, but as you saw in the previous post, I'm avoiding that path.
I was about to give up when I started to sense I might be missing something 😕.
Vulnerability
Frustrated I decided to check the tips section for the challenge, and glancing over I read reentrancy 😐.
I was kinda surprised and ashamed to read that because reentrancy is something I was pretty sure I'd recognize immediately.
Further reading: Reentrancy — SWC Registry.
The only way where a bug like this could be introduced and made sense to exploit was in this section.
And for that to happen, one of the tokens must do something that allows us to call again removeLiquidity
.
That's when it hit me. I did not check the implementation of the other token! 😳
const setTokenFactory =
await ethers.getContractFactory(“SimpleERC223Token”, deployer)
It is a custom implementation of the ERC223 token which adds an override to the function _afterTokenTransfer.
This function is called every time a token is minted, burned, or transferred!
Let's see what it does 🔎.
When removeLiquidity
executes token1.safeTransfer(msg.sender, amount1)
, the _afterTokenTransfer
function gets triggered.
If the target destination of the token is a contract, then it calls the function tokenFallback
inside that contract.
That's it! This is where the vulnerability is.
We could create a contract that triggers removeLiquidity
and upon being called after a token transfer, it can call to a malicious implementation of atokenFallback
that calls removeLiquidity
again…and again...until we drain the DEX entirely💥.
Fortunately, this is being done by token1
, which transfers before the transfer of the other token, so we get a double prize 💕.
A safer removeLiquidity.
— How would you modify this function in order to be more secure, or at least be able to avoid a reentrancy exploit? 💭
If we were to follow the CHECKS-EFFECTS-INTERACTIONS, a much safer implementation for that code would be something like the code below.
The exploit
In order to craft the exploit, we need to take some things into account.
First, we will need to transfer our tokens to the contract exploit. Then we will need to let the exploit code know when to stop the reentrancy. And finally, we must transfer the profit back to us.
9 rounds will be ok, since we start with 1 eth of each, and we can get at most 10 eth of each.
We start the sequence by calling run
which setups what we need.
We start by defining a counter on cero — since we are going only for 9 rounds — and then another variable that will enable the reentrancy exploit.
— Before continuing, I leave you as an exercise to guess why I need to be able to enable/disable the exploit. 💭
Finally, the function that will be called to abuse the reentrancy is as follows.
Checks whether we are ok to continue and that we are not past the 9th round. It increases the counter and calls again removeLiquidity
triggering the reentrancy.
After 9 rounds it will stop calling the DEX contract, and transfer the loot to the challenger account, solving the challenge.
Oh, and we also ended up with 115792089237316195423570985008687907853269984665640564039448.584007913129639936 LP tokens as a consequence of the exploitation 😅.
— Can you explain why? 💭
Solution
Exploit contract and deployment/usage of it below.
Exploit contract
Deployment
I added some printing in order to provide more visibility on the impact of what I was doing.
— Did any of the possibilities you wrote down are similar to the bug we found? What did you miss? 💭
See you in the next post! ➡️
And again, thanks for reading!
Thanks for reading! My name is Matt, and I’m learning how to make Ethereum more secure. I will be sharing some things from time to time.
Follow me on twitter @mattaereal.