1. Overview
2023년 8월 2일, Uwerx 플랫폼은 flashloan Attack 을 이용한 공격으로 약 32.7만(174WETH )달러에 해당하는 금액이 탈취되었습니다.
공격자는 WERX를 송금할 때 발생하는 취약점을 이용하여 토큰 가격을 조작하고 이익을 얻었습니다.
Uwerx측에서는 공격자에게 20%의 bounty를 제시하였으나, 현 시점에는 아직 공격자의 지갑에 있는 것으로 확인하였습니다.
What is Uwerx?
Uwerx 플랫폼은 프리랜서와 고객을 연결 시켜주는 블록체인 기반 서비스입니다.
Proof-of-work(POW) 시스템을 활용하여 모든 포트폴리오 데이터의 진위여부와 소유권을 보장합니다. 또한, 안전하고 투명한 거래를 보장하며, 모든 결제는 Uwerx 의 암호화폐(WERX)를 통해서 이루어집니다. WERX토큰을 통해 사용자는 프리미엄 기능에 엑세스하거나, 서비스 할인등을 받을 수 있습니다.
2. Vulnerability Analysis
해당 취약점은 Uwerx 컨트랙트의 함수 _transfer 에서 uniswapPoolAddress 로 WERX를 송금할때, 송금하려는 양의 1% WERX을 추가적으로 burn하기 때문에 발생하였습니다.
실제 공격자 및 컨트랙트들의 주소는 아래와 같습니다.
Attacker Address : 0x6057A831D43c395198A10cf2d7d6D6A063B1fCe4
Attacker Contract : 0xDA2CCfC4557BA55eAda3cBEbd0AEFfCf97Fc14CA
Attack Transaction : 0x3b19e152943f31fe0830b67315ddc89be9a066dc89174256e17bc8c2d35b5af8
Victim Address : 0x4306B12F8e824cE1fa9604BbD88f2AD4f0FE3c54
먼저, 취약점이 발생하는 Uwerx 컨트랙트 내부 함수를 먼저 살펴보도록 하겠습니다.
•
Uwerx.sol
◦
function transfer
function transfer(address to, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_transfer(owner, to, amount); // from, to, amount
return true;
}
Solidity
복사
transfer 는 WERX 송금을 할 때 사용되는 함수로, owner를 _msgSender()로 지정하고 from 주소지를 owner로 설정하여 _transfer 를 호출합니다.
◦
function _transfer
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount; [1]
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
if (to == uniswapPoolAddress) {
uint256 userTransferAmount = (amount * 97) / 100;
uint256 marketingAmount = (amount * 2) / 100;
uint256 burnAmount = amount - userTransferAmount - marketingAmount; [2]
emit Transfer(from, to, userTransferAmount);
emit Transfer(from, marketingWalletAddress, marketingAmount);
_burn(from, burnAmount); [3]
} else {
emit Transfer(from, to, amount);
}
_afterTokenTransfer(from, to, amount);
}
Solidity
복사
[1] _transfer 를 살펴보면, 송금자의 잔고가 송금할 양보다 큰지 확인하고, 송금자의 balance를 amount만큼 빼줍니다.
[2] 그다음 if문을 보면 to의 주소지가 uniswapPoolAddress 로 가는 경우, burnAmount 를 계산하는데 amount - (amount * 97)/100 - (amount * 2)/100 만큼 값이 들어갑니다.
실질적으로, burnAmount 는 초기 amount의 1%가 들어가게 됩니다.
[3] burnAmount 를 인자로 _burn을 호출합니다.
◦
function _burn
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");
_beforeTokenTransfer(account, address(0), amount);
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
// Overflow not possible: amount <= accountBalance <= totalSupply.
_totalSupply -= amount; [4]
}
emit Transfer(account, address(0), amount);
_afterTokenTransfer(account, address(0), amount);
}
Solidity
복사
[4] 위의 [1] 의 과정에서 송금자의 balance값을 이미 감소시켰으나, 위에서 계산한 burnAmount 만큼을 더 감소시키기 때문에 문제가 발생합니다. 쉽게 말하면, amount*1.01 를 뺀다고 생각하시면 됩니다.
1% 이지만, 대량의 금액인 경우 추가로 burn을 시키면 Pool의 토큰 가격에 큰 변동이 생기게 됩니다.
공격자는 이 취약점을 통하여, Pool의 WERX토큰을 burn 시켜 가격 변동을 통해 차익을 얻었습니다. 공격자는 큰 금액을 이용하기 위해 flashloan을 통해 공격을 진행하였고, 자세한 공격 기법은 하위 에서 설명하겠습니다.
3. Attack Flow
3.1 Summary
전체 공격 흐름을 간단히 정리하면, 아래와 같습니다.
1. 공격자는 Attacker Contract를 deploy합니다.
2. 공격자는 flashloan으로 20000WETH를 빌립니다.
3. Uniswap V2: WERX 2 Pool 을 통해 WETH→WERX로 swap을 했습니다.
4. 공격자는 imbalance를 만들기 위해 Uniswap V2: WERX 2 Pool로 4429817WERX 를 송금하였습니다.
5. 공격자의 송금으로 인해 Uniswap V2: WERX 2 Pool의 balance값과 reserve값의 차이(imbalance)가 생기게 되고, 공격자는 이를 악용하기 위해 skim(uniswapPoolAddress(0x1)) 을 호출하여, uwerx의 취약한 _transfer 가 호출되도록 하였습니다.
_transfer 는 Uniswap V2: WERX 2 Pool의 imbalance값의 1% 를 추가적으로 burn을 시키게 되고, WERX토큰의 가격이 급 상승하게 됩니다. 그 후 sync() 함수를 호출해 잔고를 업데이트합니다.
6. 공격자는 차익을 얻기 위해 WERX→WETH로 swap을 합니다.
7. 공격자는 flashloan을 repay하고, 차익을 얻게 됩니다.
uniswapPoolAddress
아래는 Step 1~7에 따라, Attack Flow와 Token Flow를 도식화시킨 자료입니다.
3.2 Attack Transaction
실제 사용된 transaction을 보면서 자세하게 설명드리도록 하겠습니다.
•
Attack Transaction : 0x3b19e152943f31fe0830b67315ddc89be9a066dc89174256e17bc8c2d35b5af8
1.
공격자는 flashloan을 이용하기 위해 공격자 컨트랙트를 만듭니다.
2.
공격자 컨트랙트는 Balancer에서 flashloan을 이용하여 20,000WETH 를 대출합니다.
3.
현재 Uniswap V2: WERX 2 Pool에는 5,097,936WERX , 174 WETH 가 담겨져 있습니다.
4.
flashloan을 통해 받은 20,000WETH 를 Uniswap V2: WERX 2 Pool(Pool로 줄여서 부르겠습니다.)을 이용하여 대략5,053,637WERX 로 swap합니다. swap후에 Pool에는 44,298WERX 정도가 남게 됩니다.
5.
공격자는 imbalance를 만들기 위해 Pool에 남겨진 WERX의 100배인 4,429,817WERX 를 Pool로 송금합니다.
6.
공격자는Uniswap V2: WERX 2 Pool의 skim 함수를 통해 취약점을 트리거합니다.
•
function skim
function skim(address to) external lock {
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
Solidity
복사
skim 은 현재 업데이트 되지 않은 풀의 reserve 값과 실제 풀의 balance를 맞추기 위한 함수로, 차액만큼 to 의 주소지로 보내서, 두 값을 맞춰주는 역할을 합니다.
skim에서는 토큰 쌍에 대해 _safeTransfer 가 호출됩니다.
value값은 현재 Pool 의 실제 잔고와 reserve값의 차액이 들어가게 됩니다.
위의 5번에서 공격자가 Pool에 442,9817WERX 를 송금했으므로, 그 송금액이 차액이 됩니다.
•
function _safeTransfer
function _safeTransfer(address token, address to, uint value) private {
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED');
}
Solidity
복사
_safeTransfer 에서는 token.call을 사용해 함수를 호출하는데, abi.encodeWithSelector(SELECTOR, to, value) 는 호출할 함수와 해당 함수에 전달될 인수를 인코딩하는 역할을 합니다. SELECTOR는 호출하려는 함수를 나타내는 바이트 값입니다.
bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));
Solidity
복사
SELECTOR는 transfer함수로 정의를 해두었습니다.
결과적으로, Uniswap V2: WERX 2 Pool의 skim → _safeTransfer → token.call → transfer(Uwerx) → _transfer(Uwerx) 순서대로 호출이 됩니다.
공격자는 skim을 호출할 때, 수신 주소를 uniswapPoolAddress(0x1) 로 설정하여 취약점을 트리거 하였습니다. skim 부터 transfer (Uwerx)까지 과정은 위에 기술하였으므로, _transfer (Uwerx) 부터 설명하겠습니다.
•
function _transfer
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
//...//
//...//
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
if (to == uniswapPoolAddress) {
//...//
uint256 userTransferAmount = (amount * 97) / 100;
uint256 marketingAmount = (amount * 2) / 100;
uint256 burnAmount = amount - userTransferAmount - marketingAmount;
_burn(from, burnAmount);
//...//
Solidity
복사
Uwerx.sol의 _transfer 에 인자가 어떻게 들어가는지 보겠습니다.
◦
From : Uniswap V2: WERX 2 Pool
◦
To : uniswapPoolAddress(0x1)
◦
Amount : 4,429,817WERX ( reserve 값과 실제 풀의 balance 차액)
◦
burnAmount : 4,429,817WERX/100 = 44,298WERX
이렇게 인자 값이 들어가게 되고, if문으로 진입하여 _burn 을 호출하게 됩니다.
•
function _burn
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");
_beforeTokenTransfer(account, address(0), amount);
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
// Overflow not possible: amount <= accountBalance <= totalSupply.
_totalSupply -= amount; [4]
}
emit Transfer(account, address(0), amount);
_afterTokenTransfer(account, address(0), amount);
}
Solidity
복사
_burn 함수의 account에 Uniswap V2: WERX 2 Pool 의 주소가 들어가게 되고, 공격자가 기존에 Pool에 있는 WERX의 100배(4,429,817WERX) 만큼 송금해서 imbalance를 만들었기 때문에, Pool은 1% 즉, 거의 Pool에 있는 잔액만큼 burn을 당하기때문에 소량의 WERX만 남게됩니다.
7.
그 다음으로, sync를 호출하여 Pool의 잔고를 update시킵니다.
pool에는 100WERX 와 20,174WETH 가 남게됩니다.
8.
차익을 얻기 위해 공격자는 4. 과정에서 Pool에 송금 하고 남은 623,820WERX 를 WERX -> WETH 로 다시 swap을 합니다. swap을 하면 WERX의 토큰 가치가 급 상승했기 때문에, Pool에 있는 전체 20,174WETH 를 받게 됩니다.
9.
최종적으로, 공격자는 flashloan으로 받은 20,000WETH 를 상환하고, 174WETH 만큼의 이익을 얻게 됩니다. 해당 금액은 공격자에게 아직 그대로 남아있습니다.
4. Conclusion
이번 취약점은 Uwerx의 _transfer 함수에서 uniswapPoolAddress 로 송금하는 로직에서 발생하였습니다. flashloan Attack은 빈번하게 발생하는 공격으로 큰 금전적 손실로 이어질 수 있습니다.
토큰을 transfer 하거나 burn 할 때는 부정확한 계산이 되지 않는지 정밀한 확인이 필요합니다.
78ResearchLab에서는 스마트 컨트랙트 코드/서비스 로직에 대한 정적/동석 분석으로 보안 감사를 수행하고 있습니다.
보안 감사를 통해 이러한 공격으로부터 안전한 Web3 환경을 만들어 드립니다.
contact@78researchlab.com 로 메일을 보내주신다면 연락드리겠습니다.