Back to Blog

Reflection 토큰에 대한 고찰: 보안적 관점

Phalcon
2024년 6월 6일
23 min read

2024년 6월 14일 업데이트: 커뮤니티 멤버가 이 블로그를 꼼꼼히 검토하여 the ADU incident에 대한 정보를 제공해 주었습니다. 이는 기존 분류에 포함되지 않은 새로운 형태입니다. 감사드리며, 통찰력 있는 모든 피드백을 환영합니다!

시장 안정성을 높이기 위해, 리플렉션 토큰(일명 리워드 토큰)은 투자자에게 추가적인 수익 창출 수단을 제공하도록 설계되었습니다. 이는 투자자가 토큰을 거래하는 대신 보유하도록 장려합니다. 악명 높았던 2021년 밈 코인 시즌 동안, 리플렉션 토큰은 필수적인 메커니즘이 되어 DxSale과 같은 플랫폼에서 출시된 후 (예: SafeMoon V1) 빠르게 시장의 주목을 받았습니다.

광기가 사그라들고 2023년 시장이 냉각되었음에도 불구하고, 우리 시스템은 실제 환경에서 이러한 토큰 메커니즘을 악용한 수만 건의 해킹 사고를 탐지했습니다. 이러한 리퍼(reaper) 스타일의 공격은 다른 유형의 DeFi 공격과 비교했을 때 규모는 상대적으로 작았지만, 사용자 자산에 무시할 수 없는 손실을 초래했습니다.

이 블로그에서는 저희 연구에서 얻은 보안 관련 인사이트를 공유하는 것에 주로 초점을 맞추고 있습니다. 구체적으로, 먼저 리플렉션 토큰의 메커니즘에 대해 간략히 소개하겠습니다. 이어서 리플렉션 토큰과 관련된 보안 사고들을 리뷰하되, 리플렉션 토큰 메커니즘을 악용한 사고들에 초점을 맞추겠습니다. 그런 다음, 이론적으로 잠재적인 보안 이슈에 대해 논의하겠습니다. 마지막으로, 완화 방안과 해결책에 대한 저희의 생각을 공유하겠습니다.

0x1 리플렉션 토큰의 메커니즘

저희가 알기로, 이 메커니즘은 Reflect Finance에 의해 처음 도입되었으며, 거래 금액의 일정 비율을 비거래적인 방식으로 모든 토큰 보유자에게 수수료로 분배하도록 설계되었습니다. 2021년 3월, 유명한 SafeMoon V1이 BNB 체인에 출시되었고, 이로 인해 리플렉션 토큰이 더욱 대중화되었습니다.

0x1.1 기본 개념

세부 사항을 다루기 전에, 더 나은 이해를 위해 몇 가지 기본 개념을 소개해야 합니다.

두 가지 종류의 공간이 있습니다: r-공간과 t-공간으로, 각각 반영된 공간(reflected space)과 실제 공간(true space)이라고 읽습니다. 두 공간의 암호화폐는 상대적인 유통량을 기준으로 한 환율을 가지고 있습니다. 또한, r-공간의 화폐는 디플레이션되는데, 즉 모든 거래마다 일정 비율이 소각되며 그 결과 소각된 양만큼 유통량에서 차감됩니다.

Alice, Bob, Eve가 모두 두 공간 모두에서 거래를 할 수 있다고 가정해 봅시다. 아래 그림에 나와 있는 것과 같습니다. Alice와 Bob이 t-공간에서 서로 거래를 진행하면, Eve는 어떠한 보상도 받지 못합니다. 그러나 셋 모두가 먼저 자신의 토큰을 r-공간으로 전환한 다음 Alice와 Bob이 서로에게 이체하도록 하면, Eve는 결국 자신의 토큰을 t-공간으로 다시 전환함으로써 수동적 소득을 얻게 됩니다. 이것이 리플렉션 토큰 메커니즘의 기본적인 아이디어입니다.

토큰의 유동성 공급 풀과 같은 모든 계정이 r-공간에서 거래할 수 있는 것은 아니라는 점에 유의하세요. 즉, 특정 계정은 r-공간에서 제외되어야 합니다.

0x1.2 컨트랙트 수준의 설명

이제 이 메커니즘을 탐구하기 위해 Reflect Finance의 REFLECT contract를 자세히 살펴보겠습니다.

이 컨트랙트는 먼저 계정 관리를 위한 여러 변수를 정의합니다:

mapping (address => uint256) private _rOwned; // reflected token held by user
mapping (address => uint256) private _tOwned; // true token held by user
mapping (address => mapping (address => uint256)) private _allowances;

mapping (address => bool) private _isExcluded; // if user is excluded from r-space
address[] private _excluded; // accounts that are excluded from r-space

그런 다음, 컨트랙트의 필수 상수들을 정의합니다. _rTotal이 _tTotal(즉, totalSupply 함수의 반환값으로 사용되는 토큰의 totalSupply)의 특정 배수로 설정된 것을 확인할 수 있습니다:

uint256 private constant MAX = ~uint256(0);
uint256 private constant _tTotal = 10 * 10**6 * 10**9;
uint256 private _rTotal = (MAX - (MAX % _tTotal));

기능 및 사용자 상호작용의 관점에서, 이 컨트랙트의 함수는 다음 세 가지 카테고리로 나뉩니다: 잔액 조회, 토큰 이체, 그리고 독특한 reflect 함수. 앞의 두 가지는 ERC-20 표준과 호환되지만, 내부 로직은 다른 ERC-20 토큰과 다릅니다. 이 함수들 각각에 대해 아래에서 자세히 설명하겠습니다.

0x1.2.1 잔액 조회 함수

잔액 계산은 제외된 사용자와 제외되지 않은 사용자에 대해 다르게 이루어집니다:

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

이는 다음과 같은 공식으로 표현할 수 있습니다:

위 공식의 rate는 _getRate 함수를 호출하여 계산되며, 이는 실제로 컨트랙트 내 _getCurrentSupply 함수의 반환값으로부터 계산됩니다.

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

function _getCurrentSupply() private view returns(uint256, uint256) {
    uint256 rSupply = _rTotal;
    uint256 tSupply = _tTotal;      
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
        rSupply = rSupply.sub(_rOwned[_excluded[i]]);
        tSupply = tSupply.sub(_tOwned[_excluded[i]]);
    }
    if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
    return (rSupply, tSupply);
}

위의 코드 스니펫으로부터 해당하는 공식을 도출하는 것은 어렵지 않습니다:

0x1.2.2 토큰 이체 함수

일반적으로, 자산을 이체하는 데는 다음과 같이 네 가지 시나리오가 있습니다:

function _transfer(address sender, address recipient, uint256 amount) private {
    require(sender != address(0), "ERC20: transfer from the zero address");
    require(recipient != address(0), "ERC20: transfer to the zero address");
    require(amount > 0, "Transfer amount must be greater than zero");
    if (_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferFromExcluded(sender, recipient, amount); // t-space -> r-space
    } else if (!_isExcluded[sender] && _isExcluded[recipient]) {
        _transferToExcluded(sender, recipient, amount); // r-space -> t-space
    } else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferStandard(sender, recipient, amount); // r-space -> r-space
    } else if (_isExcluded[sender] && _isExcluded[recipient]) {
        _transferBothExcluded(sender, recipient, amount); // t-space -> t-space
    } else {
        _transferStandard(sender, recipient, amount); // r-space -> r-space
    }
}

제외된 계정의 경우, _rOwned와 _tOwned 모두 해당 공간에서 더하거나 빼야 합니다. 제외되지 않은 계정의 경우, _rOwned만 고려하면 됩니다. 예를 들어, 아래 코드 스니펫은 r-공간에서 t-공간으로 자산을 이체하는 구현을 보여주는데, 여기서 sender는 제외되지 않은 계정이고 recipient는 제외된 계정입니다.

function _transferToExcluded(address sender, address recipient, uint256 tAmount) private {
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _tOwned[recipient] = _tOwned[recipient].add(tTransferAmount);
    _rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);           
    _reflectFee(rFee, tFee);
    emit Transfer(sender, recipient, tTransferAmount);
}
  • _getValues 함수는 두 공간 모두에 대해 해당하는 amount, transferAmount, fee를 계산합니다 (즉, amount = transferAmount + fee).

    function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256) {
        (uint256 tTransferAmount, uint256 tFee) = _getTValues(tAmount);
        uint256 currentRate =  _getRate();
        (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
        return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee);
    }
  • _reflectFee 함수는 r-공간에서 수수료를 반영합니다. 구체적으로, _rTotal은 r-공간에서의 수수료(즉, rFee)만큼 감소하며, 이는 결과적으로 rate를 낮춥니다. balanceOf(user) 계산 공식에 따라, 수수료는 이러한 방식으로 제외되지 않은 모든 토큰 보유자에게 반영됩니다.

    function _reflectFee(uint256 rFee, uint256 tFee) private {
        _rTotal = _rTotal.sub(rFee);
        _tFeeTotal = _tFeeTotal.add(tFee);
    }

0x1.2.3 reflect 함수

이체 과정에서 리플렉션 토큰 메커니즘이 수동적으로 작동하는 것 외에도, 사용자는 이 메커니즘을 능동적으로 시작하기 위해 reflect 함수를 직접 호출할 수 있습니다. 구체적으로, 자신이 보유한 토큰을 소비하여 이 함수를 호출하면, rSupply가 감소함에 따라 rate가 낮아져 다른 토큰 보유자들에게 이익을 제공하게 됩니다. 다시 말해, 타인을 위해 자신을 희생하는 것입니다. 이를 통해 프로젝트 관리자는 이 함수를 활용하여 토큰 보유자들에게 인센티브를 제공할 수 있습니다.

function reflect(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);
}

위에서 제공된 코드는 이 메커니즘의 핵심을 이룹니다. 서로 다른 토큰들은 자신들의 구현을 맞춤화하기 위해 특정한 추가 함수를 포함할 수 있습니다. 예를 들어, 일부는 고래(whale)가 토큰을 매도하기로 결정할 때 발생하는 폭락(stampede)을 방지하기 위해 "swap and liquify" 기능에 전력을 공급하는 데 거래 수수료를 사용할 수 있습니다.

0x2 해킹당한 리플렉션 토큰의 사후 분석(Post-Mortem)

앞서 언급했듯이, 저희의 주된 초점은 리플렉션 토큰 메커니즘을 악용하는 공격입니다. 따라서 이 메커니즘과 무관한 사고들, 예를 들어 SafeMoon V2 attack(일반적인 ERC20 public burn 이슈)과 최근의 ZongZi attack(스팟 가격을 악용한 구식 가격 조작과 관련됨)은 다루지 않습니다.

저희는 이러한 공격들의 근본 원인을 밝혀내기 위해 심층 분석을 수행했습니다. 이러한 모든 사고들의 목록은 here에서 확인하실 수 있습니다. 저희는 이 중 대부분이 코드 취약점 또는 부적절한 관리 운영으로 인해 발생한 정상적인(normal) 보안 사고임을 발견했습니다. 그러나 일부(예: 백도어의 존재)는 상당히 의심스러운데, 이를 저희는 비정상적인(abnormal) 보안 사고라고 부릅니다. 다음 하위 섹션에서는 먼저 정상적인 보안 사고를 소개한 다음, 비정상적인 사고들을 자세히 살펴보겠습니다.

0x2.1 정상적인 보안 사고

저희의 조사에 따르면 이러한 사고들은 코드 수준(code-level) 이슈와 운영 수준(operation-level) 이슈라는 두 가지 유형의 문제에서 개별적으로 또는 결합되어 발생합니다.

  1. 코드 수준 이슈. 이는 컨트랙트의 형편없는 구현에서 비롯되며, 개발자가 리플렉션 토큰의 메커니즘을 완전히 이해하지 못했기 때문일 가능성이 높습니다. 이는 실제 토큰 공급량과 기록된 totalSupply 값 사이의 불일치로 이어지며, 이는 rate를 조작하는 데 사용될 수 있습니다:

    • 1.1 무비용 소각(Zero-cost burn)

    • 1.2 토큰 이체 중 rSupply로부터의 추가 차감

    • 1.3 r-공간과 t-공간 값의 혼동 (정밀도 손실을 이용하여 이익을 취함)

  2. 운영 수준 이슈. 이는 관리자의 부적절한 운영에서 비롯됩니다. 구체적으로, 이러한 사고들에서는 AMM 페어 주소가 적절히 제외되지 않는 등의 부적절한 구성을 의미합니다.

나열된 모든 코드 수준 이슈들이 실제 공급량과 totalSupply 사이의 불일치를 초래할 수 있다는 점에 유의할 필요가 있습니다. 그러나 이것이 반드시 이러한 취약점들이 악용 가능하다는 것을, 더 정확히는 악용할 만한 가치가 있을 만큼 수익성이 있다는 것을 의미하지는 않습니다. 공격자가 rate를 조작하는 데 비용이 들 수도 있기 때문입니다. 간단하게 하기 위해, 이하에서는 "악용 가능하다(exploitable)"는 용어를 "악용할 만한 가치가 있을 만큼 수익성이 있다"는 의미로 사용하겠습니다. 결과적으로, 일부 시나리오에서는 이러한 취약점을 악용 가능하게 만들기 위해 운영 수준 이슈가 필요합니다.

구체적으로, 이슈 1.1은 직접적으로 악용될 수 있는 반면, 이슈 1.2와 1.3은 악용 가능해지기 위해 이슈 2와의 결합이 필요합니다. 따라서 논의 중인 사고들은 이러한 관찰을 바탕으로 Type-I과 Type-II 두 가지 유형으로 나눌 수 있습니다. 아래는 관련 데이터를 정리한 표입니다:

유형 사고 근본 원인 # (%)
I CATOSHI 코드 수준 이슈만 (1.1) 1 (0.79%)
II (a) BEVO, FETA, ADU 두 가지의 결합 (1.2 & 2) 3 (2.38%)
II (b) SHEEP 외 120여 건 두 가지의 결합 (1.3 & 2) 122 (96.83%)

표를 보면 Type-II 사고가 상당한 비율을 차지한다는 것을 알 수 있습니다. 구체적으로, Type-II에는 Type-II-a(즉, 이슈 1.2와 이슈 2)와 Type-II-b(즉, 이슈 1.3과 이슈 2)라는 2가지 변형이 존재합니다. 또한, Type-II-b 카테고리에 속하는 SHEEP과 유사한 사고들의 경우, 익스플로잇을 통해 공격자들(예: this one)이 유사하게 취약한 컨트랙트를 식별하기 위해 자동화된 방법을 사용했을 가능성이 시사됩니다. 구체적인 내용은 이후 하위 섹션에서 살펴보겠습니다.

0x2.1.1 Type-I: 코드 수준 이슈만 (이슈 1.1)

Type-I에 속하는 사고는 단 하나뿐입니다. 즉, 전체 공급량(total supply)에 영향을 미치는 무비용 소각인 the CATOSHI incident입니다.

먼저 the CATOSHI contract의 burnOf 함수를 살펴보겠습니다:

function burnOf(uint256 tAmount) public {
    uint256 currentRate = _getRate();
    uint256 rAmount = tAmount.mul(currentRate);
    _tTotal = _tTotal.sub(tAmount);
    _rTotal = _rTotal.sub(rAmount);
    emit Transfer(_msgSender(), address(0), tAmount);
}

명백하게, 이 함수에 의해 소각된 양은 호출자(즉, msg.sender)로부터 차감되지 않습니다. 그러나 _rOwned[msg.sender]는 rAmount만큼 감소해야 하며, 해당 계정이 제외된 경우 _tOwned[msg.sender]도 tAmount만큼 감소해야 합니다.

이러한 오류로 인해, 공격자는 먼저 무비용으로 대량의 토큰을 소각한 다음 컨트랙트의 reflect 함수를 호출할 수 있습니다. _tTotal과 _rTotal 모두 비례적으로 크게 감소했기 때문에:

rate는 reflect 함수를 호출하여 쉽게 하향 조작할 수 있으며, 이는 *balanceOf(attacker)*를 상당히 증가시킵니다. 이를 통해 공격자는 부풀려진 잔액으로부터 이익을 얻을 수 있습니다.

왜 그럴까요? 공격자의 새로운 잔액은 다음과 같이 계산된다는 점에 유의하세요:

*balanceOf(attacker)*와 balanceOf(attacker)' 사이의 비율은 다음과 같습니다:

다음과 같으므로

따라서

이는 공격자가 더 많은 토큰을 획득할 수 있으며, 이를 가치 있는 토큰(이 경우 WETH)으로 스왑하여 이익을 얻을 수 있음을 의미합니다.

0x2.1.2 Type-II-a: 이슈 1.2와 이슈 2의 결합

Type-II-a 사고는 두 가지 이슈의 결합을 포함합니다:

  • 이슈 1.2: 토큰 이체 중 rSupply로부터의 추가 차감.
  • 이슈 2: AMM 페어가 제외되지 않음.

Type-II-a에는 세 건의 공격 사고가 있으며, 이는 이슈 1.2에서의 취약점 형태에 따라 다음과 같이 두 가지 하위 카테고리로 더 나눌 수 있습니다:

1. _reflectFee 함수에서의 추가 반영(reflect)

이 하위 카테고리에는 두 건의 사고, 즉 the BEVO incident와 the FETA incident가 속합니다. 이하에서는 설명을 위해 the BEVO contract를 사용하겠습니다.

'토큰 이체를 위한 함수'(섹션 0x1.2.2)에서 소개했듯이, 모든 토큰 이체는 거래 수수료 일부의 반영(reflection)을 트리거합니다. BEVO에서는 원래의 수수료 외에도 수수료가 burn과 charity 두 부분으로 추가로 나뉩니다.

function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
    _rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rChairty is deducted from _rTotal
    _tFeeTotal = _tFeeTotal.add(tFee);
    _tBurnTotal = _tBurnTotal.add(tBurn);
    _tCharityTotal = _tCharityTotal.add(tCharity);
    _tTotal = _tTotal.sub(tBurn);
}

_sendToCharity 함수에서 볼 수 있듯이, charity 계정은 제외되어 있으며, 이는 이 계정으로 보내진 금액이 소각된다는 것을 의미합니다.

function _sendToCharity(uint256 tCharity, address sender) private {
    uint256 currentRate = _getRate();
    uint256 rCharity = tCharity.mul(currentRate);
    address currentCharity = _charity[0];
    _rOwned[currentCharity] = _rOwned[currentCharity].add(rCharity);
    _tOwned[currentCharity] = _tOwned[currentCharity].add(tCharity); // since charity account is excluded, the charity part is burned
    emit Transfer(sender, currentCharity, tCharity);
}

위 코드 스니펫에서, charity 부분을 반영하고 소각하는 곳이 두 군데임을 알 수 있으며, 이는 이체 과정에서 실제 토큰 공급량이 totalSupply와 불일치하게 만듭니다. 더 많은 토큰이 이체될수록, 추가적인 감소로 인해 rSupply의 값은 풀에 있는 토큰 공급량보다 작아질 것입니다.

순전히 이론적인 설명은 다소 추상적일 수 있으므로, 예시를 통해 이 과정을 명확히 해보겠습니다. Alice가 Bob에게 10개의 토큰을 이체하려 하고, 다음과 같이 3개의 토큰이 차감된다고 가정해 봅시다: 수수료로 1개, 소각으로 1개, charity로 1개. charity 부분이 반영되고 소각되므로, 실제 분해는 2개 토큰이 반영되고(수수료 1개 + charity 1개) 2개 토큰이 소각됩니다(소각 1개 + charity 1개). 여기에 Bob에게 이체될 나머지 7개 토큰을 더하면, 이 과정에는 총 11개의 토큰이 관여하게 되는데, 이는 잘못된 것입니다.

그렇다면 이러한 불일치가 어떻게 이익을 얻는 데 악용될 수 있을까요? 아래에서, 저희는 수학적 마법 지팡이를 휘둘러 그 결과를 도출해 보겠습니다.

이전에 우리가 풀(즉, PancakeSwap 페어)로부터 얻은 토큰이 있다고 가정하고, 이를 r-공간에서는 rAmount, t-공간에서는 tAmount로 표기하겠습니다. 풀이 제외되지 않았으므로, _rOwned[pair]를 rReserve로 표기하고, t-공간에서의 대응하는 값도 tReserve로 표기하겠습니다. 그러면 다음과 같습니다:

추가적인 감소로 인해, rSupply는 이제 풀에 있는 토큰 공급량보다 작아집니다:

'잔액 조회 함수' 섹션(0x1.2.1)을 상기해 보면, 현재 rate는 다음 공식을 사용하여 계산할 수 있습니다:

이때, 만약 우리가 reflect 함수(이 컨트랙트에서는 deliver 함수로 이름이 변경됨)를 통해 우리가 보유한 토큰을 반영한다면, rate는 *rate'*가 됩니다:

다음과 같으므로

우리는 다음을 얻습니다

공식 1, 3, 6을 결합하면, 다음의 부등식을 도출할 수 있습니다:

이는 풀로부터 직접(skim 함수를 통해) 획득할 수 있는 토큰의 수가 우리가 전달한 것보다 훨씬 더 많다는 것을 의미하며, reflect 함수를 호출하는 비용을 감당할 수 있기 때문에 수익성이 있게 됩니다. 그 후, 공격자는 획득한 토큰을 가치 있는 토큰(이 경우 WBNB)으로 스왑하여 이익을 얻을 수 있습니다.

BEVO 컨트랙트는 이슈 1.3에도 취약하지만, 이는 이번 공격에서는 악용되지 않았다는 점에 유의하세요.

2. _getRValues 함수에서의 잘못된 rTransferAmount 계산

이 형태에는 단 한 건의 사고, 즉 the ADU incident가 속합니다. 먼저 아래 코드 스니펫을 살펴보겠습니다.

function _transferStandard(address sender, address recipient, uint256 tAmount) private {
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
    _takeTeam(tTeam);
    _reflectFee(rFee, tFee);
    emit Transfer(sender, recipient, tTransferAmount);
}

function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
    (uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getTValues(tAmount, _taxFee, _teamFee);
    uint256 currentRate =  _getRate();
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
    return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee, tTeam);
}

function _getTValues(uint256 tAmount, uint256 taxFee, uint256 teamFee) private pure returns (uint256, uint256, uint256) {
    uint256 tFee = tAmount.mul(taxFee).div(100);
    uint256 tTeam = tAmount.mul(teamFee).div(100);
    uint256 tTransferAmount = tAmount.sub(tFee).sub(tTeam); // tTeam is deducted from tAmount
    return (tTransferAmount, tFee, tTeam);
}

function _getRValues(uint256 tAmount, uint256 tFee, uint256 currentRate) private pure returns (uint256, uint256, uint256) {
    uint256 rAmount = tAmount.mul(currentRate);
    uint256 rFee = tFee.mul(currentRate);
    uint256 rTransferAmount = rAmount.sub(rFee); // However, there is no rTeam deducted from rAmount
    return (rAmount, rTransferAmount, rFee);
}

이체 중에는 tax fee와 team fee 모두 차감되어야 함을 알 수 있습니다. 그러나 _getTvalues 함수에서는 tTransferAmount가 tFee와 tTeam 둘 다로부터 차감되는 반면, _getRValues 함수에서는 rFee만 차감됩니다. 이러한 불일치는 앞서 언급한 불일치 문제로 이어지며, 토큰 이체가 더 많이 발생할수록 이는 더욱 악화됩니다.

이 토큰에서도 페어가 제외되지 않았으므로, 이 토큰은 악용 가능합니다. 구체적으로, 공격자는 deliver 함수를 호출한 후 페어의 skim 함수를 통해 더 많은 ADU 토큰을 획득하기 위해 BEVO 익스플로잇과 유사한 방법을 사용할 수 있습니다.

그러나 당시의 온체인 상태를 고려할 때, 공격자가 획득한 ADU 토큰을 WBNB로 스왑하여 이익을 얻는 것은 불가능했습니다(tokenFromReflection 함수의 require 문 때문입니다). 따라서 공격자는 이익을 얻기 위해 더 복잡한 익스플로잇 전략을 사용해야 했을 것이며, 이는 여기서 자세히 다루지 않겠습니다.

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

0x2.1.3 Type-II-b: 이슈 1.3과 이슈 2의 결합

Type-II-b 사고는 두 가지 이슈의 결합을 포함합니다:

  • 이슈 1.3: r-공간과 t-공간 값의 혼동(이익을 얻기 위해서는 정밀도 손실도 반드시 존재해야 함에 유의).
  • 이슈 2: AMM 페어가 제외되지 않음.

이슈 1.3은 내부 _burn 함수의 구현 과정에서 r-공간과 t-공간 사이의 값을 잘못 처리하는 것에서 비롯됩니다.

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); // _rOwned should be minus a r-space value
    _tTotal = _tTotal.sub(_value); // _tTotal should be minus a t-space value
    // For the semantics of the burn function, _rTotal should also be subtracted from a r-space value.
    emit Transfer(_who, address(0), _value);
}

컨트랙트의 필수 상수들을 고려하면, r-공간 값은 일반적으로 t-공간 값보다 큰 배수입니다. 따라서 tSupply와 같은 자릿수의 _value로 burn 함수를 호출하면 rate가 크게 부풀려집니다.

그러나 Type-I 사례와 달리, 호출자는 자신의 잔액을 그대로 유지하면서 토큰을 소각할 수 없습니다. 다시 말해, 공격자가 더 많은 토큰을 획득하는 것은 불가능하지는 않더라도 어렵습니다. 그렇다면 Type-II-b 사례들은 어떻게 악용 가능할 수 있을까요?

SHEEP token incident를 예로 들어보겠습니다. 공격자가 보유한 SHEEP 토큰의 가치는 다음과 같이 표기할 수 있습니다:

여기서 SHEEP의 가격은 PancakeSwap 페어의 현물 가격(spot price)으로 표현할 수 있으며, 다음과 같이 계산됩니다:

그러면 Value는 다음과 같이 추가로 표현될 수 있습니다:

이후 공격자는 burn 함수를 반복적으로 실행하고 최종적으로 페어를 동기화(synchronize)합니다. 공격자와 페어 모두 제외되지 않았으므로, 앞서 언급한 rate 부풀림으로 인해 이들의 잔액이 감소합니다. 따라서 비율은 다음과 같이 조정됩니다:

앞선 정의를 바탕으로, 이러한 비율들을 다음과 같이 추가로 표현할 수 있습니다:

여기서 X는 소각된 _value의 합을 나타냅니다.

여기서 마법이 일어납니다: 8과 9를 더 단순화하면 후자의 비율이 명백히 전자보다 작다는 것을 알 수 있는데, 이는 이 경우 수익이 음수 값이 되므로 저희를 의아하게 만듭니다:

사실, 공격자는 리플렉션 토큰의 정밀도 손실(precision loss) 이슈를 이용했습니다. 제외되지 않은 사용자의 경우, 저희가 제공한 공식에 따라, 잔액 계산은 실제로 tokenFromReflection 함수에서 내림(round down) 처리됩니다. 따라서 balanceOf 조회의 반환값은 이론적 값보다 작을 수 있습니다. 즉, 이 문제를 고려하면 *ratio'*가 ratio보다 클 수 있습니다.

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

attack transaction을 디버깅함으로써, 저희는 이러한 조작 전후 공격자와 페어의 이론적 잔액을 계산할 수 있습니다. 저희 계산 결과는 아래 표에 정리되어 있습니다:

표의 Δ는 1보다 훨씬 작은 극히 작은 값입니다.

페어의 sync 함수 내에서 trace를 분석함으로써, 공격자와 페어의 이론적 잔액이 실제로는 각각 27.523과 2.972이며, 그 결과 비율은 9.26이라는 것을 계산할 수 있습니다. 그러나 정밀도 손실로 인해, 잔액은 각각 27과 2로 내림 처리되어 비율이 13.50으로 부풀려집니다. 그 결과, Profit이 양수 값이 됩니다.

마지막으로, 공격자는 역방향 스왑을 수행하여 이익을 얻을 수 있습니다.

0x2.2 비정상적인 보안 사고

이 하위 섹션에서는 FDP와 DBALL 토큰에 대한 조사 결과를 공유하겠습니다. 저희 분석에 따르면 FDP와 DBALL 토큰의 관리자 모두 문제가 있는 권한 있는(privileged) 함수를 호출했으며, 이는 사실상 백도어로 작용하여 프로젝트를 위험에 빠뜨렸고 결국 공격으로 이어졌습니다. 구체적으로, DBALL 프로젝트에서는 토큰 소유자에 의한 일련의 의심스러운 트랜잭션들을 확인했는데, 이는 이를 **러그풀(rug pull)**로 간주할 명확한 증거를 제공합니다.

이 두 토큰을 대상으로 한 악용 방식은 0x2.1.2에서 설명한 'Type-II-a: 이슈 1.2와 이슈 2의 결합' 섹션에서 논의된 것과 매우 유사합니다. 그러나 실제 토큰 공급량이 totalSupply와 다를 수 있는 이유를 분석할 때, 몇 가지 의심스러운 활동이 드러납니다.

0x2.2.1 FDP 사고

FDP 사례에서 실제 토큰 공급량과 totalSupply 사이의 불일치는 컨트랙트 소유자만 호출할 수 있는 권한 있는 함수인 transferOwnership 함수의 호출에서 비롯됩니다. 이름에서 알 수 있듯이, 이 함수는 컨트랙트의 소유권을 변경하는 것으로 되어 있습니다. 그러나 the FDP contract에서, 이 함수는 소유권 이전과 아무런 관련이 없습니다. 대신, totalSupply를 변경하지 않고 _rOwned[newOwner]를 증가시킵니다. 이는 정상적인 토큰 발행(minting) 과정의 설계 원칙을 명백히 위반합니다.

function transferOwnership(address newOwner) public virtual onlyOwner {
    (, uint256 rTransferAmount,, uint256 tTransferAmount,,) = _getValues(_tTotal);
    _rOwned[newOwner] = _rOwned[newOwner].add(rTransferAmount);       
    emit Transfer(address(0), newOwner, tTransferAmount);
}

이 함수를 호출한 트랜잭션들은 다음 표에 정리되어 있습니다:

0x2.2.2 DBALL 사고

DBALL 사례는 상황이 더 복잡합니다. MetaSleuth를 사용하여 the DBALL owner의 자금 흐름을 분석한 결과, 이 주소로의 DBALL 토큰 유입과 유출 사이에 불균형이 관찰되었으며, this transaction의 자금 출처가 기록되지 않은 것을 발견했습니다.

체인상의 과거 상태를 조회함으로써, 저희는 마침내 this transaction 전후로 소유자의 DBALL 잔액이 변경되었음을 확인했습니다. 소유자가 t-공간에서 1개의 토큰을 소각하기 위해 권한 있는 manualDevBurn 함수를 호출했음을 관찰할 수 있습니다. 이 함수의 구현은 다음과 같습니다:

function manualDevBurn (uint256 tAmount) public onlyOwner() {  
    uint256 rAmount = tAmount.mul(_getRate());
    if (_isExcluded[_msgSender()]) {
        _tOwned[_msgSender()] = _tOwned[_msgSender()] - (tAmount);
    }
    _rOwned[_msgSender()] = _rOwned[_msgSender()] - (rAmount);
    
    _tOwned[address(0)] = _tOwned[address(0)] + (tAmount);
    _rOwned[address(0)] = _rOwned[address(0)] + (rAmount);
    
    emit Transfer(_msgSender(), address(0), tAmount);
}

얼핏 보기에는 모든 것이 정상인 것처럼 보입니다. 그러나 컨트랙트가 0.8보다 낮은 컴파일러 버전을 지정하고 있기 때문에, _rOwned[_msgSender()]의 차감 과정에서 **산술 언더플로우(arithmetic underflow)**가 발생하여, 0에서 거의 type(uint256).max로 전환됩니다. 이 미묘한 조작을 통해 소유자는 자신의 잔액을 변경할 수 있지만, 이는 또한 토큰 공급량의 불일치를 초래합니다.

이것이 단순한 우발적 실수일까요? 저희의 조사는 이것이 의도적인 러그풀일 가능성이 더 높다는 것을 시사합니다. 그 이유는 다음과 같이 정리됩니다:

  1. 소유자는 manualDevBurn 함수에 단 1개의 토큰만 전달했지만, 30분 이내에 this transaction을 통해 전체 공급량과 동일한 양의 DBALL이 associated address로 이체되었습니다.

  2. 그 연관된 주소는 즉시 PancakeSwap 페어에서 스왑을 진행하여 약 56 WBNB를 획득했습니다.

  1. 이 두 주소의 자금 흐름을 분석한 결과, 두 주소 모두 결국 Tornado.Cash를 통해 BNB를 이체한 것으로 드러났습니다.

0x3 rate 계산에서의 잠재적 이슈

앞서 논의한 사고들 외에도, 저희는 이론적으로 더 논의할 가치가 있는, 제외되지 않은 사용자의 잔액 계산에 잠재적인 이슈가 존재한다는 것을 발견했습니다. 이 이슈는 rate 계산 과정에서 발생할 수 있습니다.

_getCurrentSupply 함수를 살펴보겠습니다. 이 함수에서, 마지막의 if 문은 rSupply가 초기 rate(즉, _rTotal / _tTotal)보다 작은지를 판단합니다.

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

function _getCurrentSupply() private view returns(uint256, uint256) {
    uint256 rSupply = _rTotal;
    uint256 tSupply = _tTotal;      
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
        rSupply = rSupply.sub(_rOwned[_excluded[i]]);
        tSupply = tSupply.sub(_tOwned[_excluded[i]]);
    }
    if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
    return (rSupply, tSupply);
}

컨트랙트 배포부터 프로젝트 출시까지의 생명주기 동안, 이 조건문이 참이라면 제외되지 않은 모든 사용자의 잔액은 0이 될 것입니다. 그러나 프로젝트가 출시되고 거래가 시작되면, 이 if 문의 원래 의도는 상실됩니다.

리플렉션 토큰 메커니즘으로 인해 rSupply가 감소하므로, rate도 그에 따라 감소합니다. 만약 특정 트랜잭션 후에 rSupply가 초기 rate 아래로 떨어지면, 현재 rate가 급증(jump)하게 되어 제외되지 않은 모든 사용자에게 잔액 손실이 발생합니다. 또한, 이론적으로 다음과 같은 상황이 발생할 수 있습니다.

이는 정밀도 손실로 인해 rate가 0이 되게 하여, 0으로 나누는 패닉(divide-by-zero panic)을 발생시킬 가능성이 있습니다.

0x4 완화 및 해결책

리플렉션 토큰 메커니즘은 투자자가 거래하는 대신 토큰을 보유하도록 유인하여 추가적인 보상을 받도록 함으로써 시장 안정성을 강화하는 방법을 제공합니다. 그러나 이는 r-공간과 t-공간 값의 혼동과 같은 새로운 보안 문제와 잠재적 위험 또한 초래합니다. 따라서 블록체인 개발자와 투자자가 이 메커니즘과 그 잠재적 위험에 대해 더 잘 이해하고 해결책을 모색하는 것이 중요합니다.

BlockSec은 출시 전 및 출시 후 단계 모두를 위한 보안 서비스와 제품을 제공합니다. 저희의 보안 감사 서비스는 코드의 보안성과 투명성을 보장하기 위해 철저한 검토를 수행합니다. 저희의 Phalcon 제품은 지속적인 보안 모니터링과 공격 탐지 기능을 제공하여, 운영자와 투자자가 프로젝트를 모니터링하고 보안 위험이 감지되었을 때 자동으로 조치를 취할 수 있도록 합니다.

관련 읽을거리


BlockSec 소개

BlockSec은 풀스택 Web3 보안 서비스 제공업체입니다. 회사는 신흥 Web3 세계의 대중적 채택을 촉진하기 위해 보안성과 사용성을 향상시키는 데 전념하고 있습니다. 이를 위해, BlockSec은 smart contract and EVM chain security auditing services, 보안 개발 및 위협을 선제적으로 차단하기 위한 Phalcon platform, 자금 추적 및 조사를 위한 MetaSleuth platform, 그리고 web3 빌더들이 암호화폐 세계를 효율적으로 탐색할 수 있도록 하는 MetaSuites 확장 프로그램을 제공합니다.

현재까지, 회사는 Uniswap Foundation, Compound, Forta, PancakeSwap과 같은 1,000곳 이상의 고객을 서비스했으며, Matrix Partners, Vitalbridge Capital, Fenbushi Capital을 비롯한 저명한 투자자들로부터 두 차례의 자금 조달을 통해 수천만 달러를 유치했습니다.