기술적 관점에서 분석하기: 왜 디플레이션 메커니즘의 토큰이 공격받기 쉬운가
저자: Eocene Research
개요
최근 블록체인에서 디플레이션 메커니즘을 가진 토큰이 자주 공격받고 있습니다. 본 문서에서는 토큰이 공격받는 이유를 논의하고 분석하며, 이에 대한 방어 방안을 제시합니다.
토큰에서 디플레이션 메커니즘을 구현하는 방법은 일반적으로 두 가지가 있습니다. 하나는 소각 메커니즘이고, 다른 하나는 리플렉션 메커니즘입니다. 아래에서는 이 두 가지 구현 방식에서 발생할 수 있는 문제를 분석하겠습니다.
소각 메커니즘
일반적으로 소각 메커니즘을 가진 토큰은 그 _transfer 함수에서 소각 로직을 구현합니다. 때때로 송신자가 수수료를 부담하는 경우가 있습니다. 이 경우 수신자가 받는 토큰 수는 변하지 않지만, 송신자는 수수료를 부담해야 하므로 더 많은 토큰을 지불해야 합니다. 아래는 간단한 예입니다:
function _transfer(address sender, address recipient, uint256 amount) internal virtual returns (bool) {
require(_balances[sender] >= amount, "ERC20: transfer amount exceeds balance");
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
burnFee = amount * burnFeeRate;
_balances[sender] -= amount;
_burn(sender, burnFee);
_balances[recipient] += amount;
}
그런 다음 우리는 이러한 경우에 발생할 수 있는 위험에 대해 논의합니다.
토큰 계약만 보면 이러한 작성 방식에는 문제가 없지만, 블록체인에는 많은 복잡한 상황이 있으며, 우리는 많은 측면을 고려해야 합니다.
일반적으로 토큰에 가격을 부여하기 위해 프로젝트 측은 Uniswap, Pancakeswap 등의 탈중앙화 거래소에 토큰의 유동성을 추가합니다.
그 중 Uniswap에는 skim이라는 함수가 있으며, 이 함수는 유동성 풀에서 두 가지 토큰의 잔액과 준비금의 차이를 호출자에게 이전하여 잔액과 준비금을 균형 있게 맞춥니다:
function skim(address to) external lock {
address _token0 = token0; // 가스 절약
address _token1 = token1; // 가스 절약
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
이때 송신자는 유동성 풀이 되고, _transfer를 호출할 때 유동성 풀의 토큰이 일부 소각되어 토큰 가격이 일부 상승하게 됩니다.
공격자는 이 특성을 이용하여 토큰을 유동성 풀에 직접 전송한 다음 skim 함수를 호출하여 인출하고, 이 작업을 여러 번 반복하여 유동성 풀에서 대량의 토큰이 소각되고 가격이 급등한 후, 마지막으로 토큰을 판매하여 이익을 얻습니다.
실제 공격 사례로는 winner doge (WDOGE)가 있습니다:
function _transfer(address sender, address recipient, uint256 amount) internal virtual returns (bool) {
require(_balances[sender].amount >= amount, "ERC20: transfer amount exceeds balance");
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
if(block.timestamp >= openingTime && block.timestamp <= closingTime) {
_balances[sender].amount -= amount;
_balances[recipient].amount += amount;
emit Transfer(sender, recipient, amount);
} else {
uint256 onePercent = findOnePercent(amount);
uint256 tokensToBurn = onePercent * 4;
uint256 tokensToRedistribute = onePercent * 4;
uint256 toFeeWallet = onePercent * 1;
uint256 todev = onePercent * 1;
uint256 tokensToTransfer = amount - tokensToBurn - tokensToRedistribute - toFeeWallet - todev;
_balances[sender].amount -= amount;
_balances[recipient].amount += tokensToTransfer;
_balances[feeWallet].amount += toFeeWallet;
_balances[dev].amount += todev;
if (!_balances[recipient].exists) {
_balanceOwners.push(recipient);
_balances[recipient].exists = true;
}
redistribute(sender, tokensToRedistribute);
_burn(sender, tokensToBurn);
emit Transfer(sender, recipient, tokensToTransfer);
}
return true;
}
WDOGE 계약의 _transfer 함수에서 block.timestamp > closingTime일 때 else 루프에 들어갑니다. 코드 21행에서 전송 금액이 송신자의 잔액에서 차감되고, 코드 31행에서 송신자는 tokensToBurn 수량의 토큰이 소각됩니다. 공격자는 이 수수료 메커니즘을 이용하여 위의 공격 방식을 통해 유동성 풀의 모든 가치 토큰(WBNB)을 훔칩니다.
반사 메커니즘
반사 메커니즘에서는 사용자가 거래할 때마다 수수료가 부과되어 토큰을 보유한 사용자에게 보상을 주지만, 전송을 트리거하지 않고 단순히 계수를 수정합니다.
이 메커니즘에서 사용자는 두 가지 유형의 토큰 수량인 tAmount와 rAmount를 가집니다. tAmount는 실제 토큰 수량이고, rAmount는 반영된 토큰 수량으로 비율은 tTotal / rTotal입니다. 일반적인 코드 구현은 다음과 같습니다:
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]);
}
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
반사 메커니즘의 토큰에는 일반적으로 deliver라는 함수가 있으며, 이 함수는 호출자의 토큰을 소각하고 rTotal의 값을 줄여 비율이 증가하게 하며, 다른 사용자의 반사된 토큰 수량도 증가합니다:
function deliver(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount);
_tFeeTotal = _tFeeTotal.add(tAmount);
}
공격자는 이 함수를 주목하고 이를 이용해 해당 Uniswap의 유동성 풀을 공격합니다.
그렇다면 어떻게 활용할 수 있을까요? 마찬가지로 Uniswap의 skim 함수에서 시작합니다:
function skim(address to) external lock {
address _token0 = token0; // 가스 절약
address _token1 = token1; // 가스 절약
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
Uniswap에서 reserve는 준비금으로, token.balanceOf(address(this))와는 다릅니다.
공격자는 먼저 deliver 함수를 호출하여 자신의 토큰을 소각하여 rTotal의 값이 줄어들게 하고 비율이 증가하게 하여 반사된 토큰의 값도 증가시키며, token.balanceOf(address(this))도 그에 따라 증가하여 reserve의 값과 차이를 발생시킵니다.
따라서 공격자는 skim 함수를 호출하여 두 값 간의 차이에 해당하는 수량의 토큰을 인출하여 이익을 얻을 수 있습니다.
Attacker: token.deliver
rtotal: decrease
rate: increase
tokenFromReflection: increase
balanceOf: increase -> token.balanceOf(address(this)) > reserve
Attacker: pair.skim
token.balanceOf(address(this)) > reserve
token.transfer
실제 공격 사례로는 BEVO NFT Art Token (BEVO)가 있습니다:
그리고 토큰 계약에 burn 함수가 존재할 경우, 또 다른 유사한 공격 방식이 존재합니다:
function burn(uint256 _value) public {
_burn(msg.sender, _value);
}
function _burn(address _who, uint256 _value) internal {
require(_value <= _rOwned[_who]);
_rOwned[_who] = _rOwned[_who].sub(_value);
_tTotal = _tTotal.sub(_value);
emit Transfer(_who, address(0), _value);
}
사용자가 burn 함수를 호출하면 자신의 토큰이 소각되고 tTotal의 값이 줄어들어 비율이 감소하게 되며, 이에 따라 반사된 토큰 수량도 줄어들게 됩니다. 이때 유동성 풀의 토큰 수량도 줄어들어 토큰 가격이 상승하게 됩니다.
공격자는 이 특성을 이용하여 burn 함수를 여러 번 호출하여 tTotal의 값을 줄인 다음, 유동성 풀의 sync 함수를 호출하여 reserve와 balances를 동기화합니다. 마지막으로 유동성 풀의 토큰이 대폭 줄어들고 가격이 급등합니다. 그런 다음 공격자는 토큰을 판매하여 이익을 얻습니다.
Attacker: token.burn
tTotal: decrease
rate: decrease
tokenFromReflection: decrease
balanceOf: decrease
Attacker: pair.sync
token.balanceOf(address(this)) > reserve
token.transfer
실제 공격 사례로는 Sheep Token (SHEEP)가 있습니다:
방어 방안
소각 메커니즘과 반사 메커니즘을 가진 토큰에 대한 공격 수법을 해석해 보면, 공격자가 공격하는 핵심 포인트는 유동성 풀의 가격을 조작하는 것이므로, 유동성 풀의 주소를 화이트리스트에 추가하고, 토큰의 소각에 관여하지 않으며, 토큰의 반사 메커니즘에 참여하지 않도록 하면 이러한 공격을 피할 수 있습니다.
결론
본 문서에서는 디플레이션 메커니즘을 가진 토큰의 두 가지 구현 메커니즘과 이 두 가지 메커니즘에 대한 공격 수단을 분석하고, 마지막으로 이에 대한 해결책을 제시했습니다. 계약을 작성할 때 프로젝트 측은 토큰과 탈중앙화 거래소의 결합 상황을 고려해야 하며, 이러한 공격을 피해야 합니다.