2024년 6월 14일 업데이트: 커뮤니티 회원이 이 블로그를 꼼꼼히 살펴보고 ADU 사건에 대한 정보를 제공해 주었습니다. 이는 이전 분류에서 다루지 않은 새로운 형태입니다. 감사합니다. 통찰력 있는 모든 피드백을 환영합니다!
시장 안정성을 강화하기 위해, 리플렉션 토큰(보상 토큰이라고도 함)은 투자자들에게 수익을 올릴 수 있는 추가적인 수단을 제공하도록 설계되었습니다. 이는 투자자들이 토큰을 거래하는 대신 보유하도록 장려합니다. 악명 높은 2021년 밈 코인 시즌 동안, 리플렉션 토큰은 필수적인 메커니즘이 되었으며, DxSale과 같은 플랫폼에서 출시된 후 빠르게 시장의 주목을 받았습니다 (예: SafeMoon V1).
열기가 가라앉고 2023년 시장이 냉각되었음에도 불구하고, 우리 시스템은 야생에서 이러한 토큰 메커니즘을 악용한 수만 건의 해킹 사건을 감지했습니다. 이러한 리퍼 스타일의 공격은 다른 유형의 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 컨트랙트를 자세히 살펴보며 이 메커니즘을 탐구해 보겠습니다.
이 컨트랙트는 먼저 계정 관리를 위한 여러 변수를 정의합니다:
mapping (address => uint256) private _rOwned; // 사용자가 보유한 반영 토큰
mapping (address => uint256) private _tOwned; // 사용자가 보유한 실제 토큰
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // 사용자가 r-공간에서 제외되는지 여부
address[] private _excluded; // r-공간에서 제외된 계정들
그런 다음, 컨트랙트의 필수 상수를 정의합니다. _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-공간 -> r-공간
} else if (!_isExcluded[sender] && _isExcluded[recipient]) {
_transferToExcluded(sender, recipient, amount); // r-공간 -> t-공간
} else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
_transferStandard(sender, recipient, amount); // r-공간 -> r-공간
} else if (_isExcluded[sender] && _isExcluded[recipient]) {
_transferBothExcluded(sender, recipient, amount); // t-공간 -> t-공간
} else {
_transferStandard(sender, recipient, amount); // r-공간 -> r-공간
}
}
제외된 계정의 경우, _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함수는 두 공간 모두에 대한 해당 금액, 전송 금액, 수수료(즉, 금액 = 전송 금액 + 수수료)를 계산합니다.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);
}
위에 제공된 코드는 메커니즘의 핵심을 형성합니다. 다양한 토큰은 구현을 맞춤화하기 위해 특정 추가 기능을 통합할 수 있습니다. 예를 들어, 일부 토큰은 고래가 토큰을 매도할 때 발생하는 뱅크런을 방지하기 위해 거래 수수료를 활용하여 "스왑 및 유동성 공급" 기능을 구동할 수 있습니다.
0x2 망가진 리플렉션 토큰의 사후 분석
앞서 언급했듯이, 우리의 주요 초점은 리플렉션 토큰 메커니즘을 악용하는 공격입니다. 따라서 SafeMoon V2 공격 (일반적인 ERC20 공개 소각 문제)이나 최근의 ZongZi 공격 (현물 가격을 악용하는 구식 가격 조작 관련)과 같이 이 메커니즘과 관련 없는 사건들은 다루지 않습니다.
우리는 근본 원인을 명확히 하기 위해 이러한 공격들을 심층 분석했습니다. 이러한 모든 사건의 목록은 여기를 참조하세요. 대부분은 코드 취약점이나 부적절한 관리 운영으로 인한 일반적인 보안 사건임을 발견했습니다. 그러나 일부는 매우 의심스럽고 (예: 백도어의 존재), 이를 비정상적인 보안 사건이라고 부릅니다. 다음 하위 섹션에서는 먼저 일반적인 보안 사건을 소개하고 비정상적인 사건들을 자세히 살펴볼 것입니다.
0x2.1 일반적인 보안 사건
우리의 조사에 따르면 이러한 사건들은 두 가지 유형의 문제, 즉 코드 수준 문제와 운영 수준 문제에서 비롯되며, 개별적으로 또는 조합하여 발생합니다.
-
코드 수준 문제. 이는 컨트랙트의 조잡한 구현에서 비롯되며, 개발자들이 리플렉션 토큰의 메커니즘을 완전히 이해하지 못한 것으로 보이며, 토큰의 실제 공급량과
totalSupply의 기록된 값 사이의 불일치를 초래하여 rate를 조작하는 데 사용될 수 있습니다:-
1.1 무비용 소각
-
1.2 토큰 전송 중
rSupply에서 추가 차감 -
1.3 r-공간과 t-공간 값의 혼동 (이익을 내기 위한 정밀도 손실 포함)
-
-
운영 수준 문제. 이는 관리자의 부적절한 운영에서 비롯됩니다. 구체적으로, 이러한 사건에서 이는 제대로 제외되지 않은 AMM 페어 주소의 부적절한 구성을 의미합니다.
주목할 가치가 있는 점은, 나열된 코드 수준 문제들 모두 실제 공급량과 totalSupply 사이의 불일치를 초래하여 컨트랙트를 취약하게 만들 수 있다는 것입니다. 그러나, 이것이 반드시 이러한 취약점들이 악용 가능하다거나, 더 정확히는, 악용할 만큼 충분히 수익성이 있다는 것을 의미하지는 않습니다, 공격자가 rate를 조작하는 데 비용이 발생할 수 있기 때문입니다. 간단히 말해, 이하에서 우리는 "악용 가능한"이라는 용어를 "악용할 만큼 충분히 수익성이 있는"의 의미로 사용할 것입니다. 결과적으로, 일부 시나리오에서 이러한 취약점을 악용 가능하게 만들기 위해 운영 수준 문제가 필요합니다.
구체적으로, 문제 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 내에는 2가지 변형이 있습니다: Type-II-a (즉, 문제 1.2와 문제 2) 및 Type-II-b (즉, 문제 1.3과 문제 2). 또한, Type-II-b 범주의 SHEEP 유사 사건의 경우, 공격(예: 이것)은 공격자들이 자동화된 방법을 사용하여 유사하게 취약한 컨트랙트를 식별하고 있을 수 있음을 시사합니다. 구체적인 세부 사항은 다음 하위 섹션에서 탐구될 것입니다.
0x2.1.1 Type-I: 코드 수준 문제만 (문제 1.1)
Type-I에 속하는 사건은 단 하나, 즉 총 공급량에 영향을 미치는 무비용 소각인 CATOSHI 사건입니다.
먼저 CATOSHI 컨트랙트의 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 모두 비례적으로 상당히 감소했기 때문에:

reflect 함수를 호출하여 rate를 쉽게 하향 조작할 수 있으며, 이는 *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 함수에서의 추가 반영
두 가지 사건이 이 하위 범주에 속합니다, 즉 BEVO 사건과 FETA 사건입니다. 이하에서는 BEVO 컨트랙트를 사용하여 설명하겠습니다.
'토큰 전송 함수' (섹션 0x1.2.2)에서 소개한 바와 같이, 모든 토큰 전송은 거래 수수료의 일부를 반영하도록 트리거합니다. BEVO에서는 수수료가 원래 것 외에 두 가지 추가 부분으로 나뉩니다: 소각 및 자선.
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rCharity가 _rTotal에서 차감됨
_tFeeTotal = _tFeeTotal.add(tFee);
_tBurnTotal = _tBurnTotal.add(tBurn);
_tCharityTotal = _tCharityTotal.add(tCharity);
_tTotal = _tTotal.sub(tBurn);
}
자선 계정은 제외되어 있으며, 이는 이 계정으로 전송된 금액이 소각된다는 것을 의미하며, _sendToCharity 함수에서 나타납니다.
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); // 자선 계정이 제외되어 있으므로 자선 부분은 소각됨
emit Transfer(sender, currentCharity, tCharity);
}
위 코드 스니펫에서 자선 부분을 반영하고 소각하는 두 곳이 있어, 전송 중에 실제 토큰 공급량이 totalSupply와 불일치하게 됩니다. 더 많은 토큰이 전송될수록, rSupply의 값은 추가 감소로 인해 풀의 토큰 공급량보다 적어집니다.
순전히 이론적인 설명은 다소 추상적일 수 있으므로, 예시를 사용하여 프로세스를 명확히 해 보겠습니다. Alice가 Bob에게 10개의 토큰을 전송하려 하고, 3개의 토큰이 다음과 같이 차감된다고 가정합니다: 1개는 수수료, 1개는 소각, 1개는 자선. 자선 부분이 반영되고 소각되므로, 실제 분류는 2개 토큰 반영(1개 수수료 + 1개 자선)과 2개 토큰 소각(1개 소각 + 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 계산
이 형태에 속하는 사건은 단 하나, 즉 ADU 사건입니다. 먼저 아래 코드 스니펫을 살펴보겠습니다.
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이 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); // 그러나 rTeam이 rAmount에서 차감되지 않음
return (rAmount, rTransferAmount, rFee);
}
전송 중에 세금 수수료와 팀 수수료 모두 차감되어야 함을 알 수 있습니다. 그러나 _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는 r-공간 값을 차감해야 함
_tTotal = _tTotal.sub(_value); // _tTotal은 t-공간 값을 차감해야 함
// 소각 함수의 의미론적으로, _rTotal도 r-공간 값에서 차감되어야 함.
emit Transfer(_who, address(0), _value);
}
컨트랙트의 필수 상수를 고려하면, r-공간 값은 일반적으로 t-공간 값의 큰 배수입니다. 따라서 tSupply와 같은 크기의 _value로 burn 함수를 호출하면 rate가 크게 부풀려집니다.
그러나 Type-I 경우와 달리, 호출자는 자신의 잔액을 변경하지 않고 토큰을 소각할 수 없습니다. 다시 말해, 공격자가 더 많은 토큰을 수확하는 것은 어렵거나, 불가능하지는 않더라도 어렵습니다. 따라서, Type-II-b 경우는 어떻게 악용 가능할까요?
SHEEP 토큰 사건을 예로 들어 보겠습니다. 공격자가 보유한 SHEEP 토큰의 가치는 다음과 같이 표현될 수 있습니다:

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

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

공격자는 그런 다음 burn 함수를 반복적으로 실행하고 결국 페어를 동기화합니다. 공격자도 페어도 제외되지 않았으므로, 앞서 언급한 rate 인플레이션으로 인해 두 잔액이 감소합니다. 따라서 비율은 다음과 같이 조정됩니다:

이전 정의를 기반으로, 이러한 비율을 다음과 같이 더 표현할 수 있습니다:

여기서 X는 소각된 _value의 합계를 나타냅니다.
여기서 마법이 등장합니다: 8과 9를 더 단순화하면 후자의 비율이 전자보다 명백히 작아지는데, 이 경우 이익이 음수 값이 되기 때문에 우리는 의아해합니다:

사실, 공격자는 리플렉션 토큰의 정밀도 손실 문제를 이용했습니다. 제외되지 않은 사용자의 경우, 우리가 제공한 공식에 따르면 잔액 계산은 실제로 tokenFromReflection 함수에서 반올림됩니다. 따라서 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);
}
공격 트랜잭션을 디버깅하여, 조작 전후 공격자와 페어의 이론적 잔액을 계산할 수 있습니다. 계산 결과는 아래 표에 정리되어 있습니다:
표의 Δ는 1보다 훨씬 작은 극히 작은 값입니다.
페어의 sync 함수 내 트레이스를 분석하여, 공격자와 페어의 이론적 잔액이 실제로 각각 27.523과 2.972이며, 이는 9.26의 비율을 초래함을 계산할 수 있습니다. 그러나 정밀도 손실로 인해 잔액은 각각 27과 2로 반올림되어 비율이 13.50으로 부풀려집니다. 결과적으로 Profit이 양수 값이 됩니다.
마지막으로, 공격자는 역방향 스왑을 수행하여 이익을 얻을 수 있습니다.
0x2.2 비정상적인 보안 사건
이 하위 섹션에서는 FDP 및 DBALL 토큰 조사에서 얻은 결과를 공유할 것입니다. 우리의 분석에 따르면, FDP와 DBALL 토큰 모두의 관리자가 백도어로 효과적으로 작동하는 문제 있는 특권 함수를 호출하여 프로젝트를 위험에 빠뜨리고 결국 공격으로 이어졌습니다. 특히, DBALL 프로젝트에서는 토큰 소유자가 일련의 의심스러운 트랜잭션을 수행했으며, 이는 러그 풀로 간주할 수 있는 명확한 증거를 제공합니다.
이 두 토큰을 대상으로 한 익스플로잇은 0x2.1.2에서 설명된 'Type-II-a: 문제 1.2와 문제 2의 결합' 섹션에서 논의한 것과 매우 유사합니다. 그러나 실제 토큰 공급량이 totalSupply와 다를 수 있는 이유를 분석할 때, 일부 의심스러운 활동이 드러납니다.
0x2.2.1 FDP 사건
FDP 경우에서 실제 토큰 공급량과 totalSupply 사이의 불일치는 컨트랙트 소유자만 호출할 수 있는 특권 함수인 transferOwnership 함수의 호출에서 비롯됩니다. 이름에서 알 수 있듯이, 이 함수는 컨트랙트의 소유권을 변경하는 것으로 가정됩니다. 그러나 FDP 컨트랙트에서 이 함수는 소유권 이전과 아무 관련이 없습니다. 대신, totalSupply를 변경하지 않고 _rOwned[newOwner]를 증가시킵니다. 이는 일반 토큰 발행 프로세스의 설계 원칙을 명백히 위반합니다.
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);
}
이 함수를 호출한 트랜잭션은 다음 표에 요약되어 있습니다:
| 타임스탬프 | 트랜잭션 해시 | 호출자 | newOwner |
|---|---|---|---|
| 2021-06-05 17:23:57 | 0x46fa1f97...4606d9bc | 0xef309c...262586 | 0x9e96af...24481a |
| 2021-06-05 17:24:06 | 0x686e0d82...d6ebfb62 | 0xef309c...262586 | 0xb0c426...a72063 |
| 2021-06-05 17:26:12 | 0x44285339...7a526320 | 0xef309c...262586 | 0xef309c...262586 |
| 2021-06-05 19:39:00 | 0xaff7a688...dfe3f344 | 0xef309c...262586 | 0x9e96af...24481a |
| 2022-06-05 18:08:10 | 0x2c413604...f7718f25 | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 18:49:16 | 0x8f4309ca...97d4bcec | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 23:33:44 | 0xaa029544...3ff7b629 | 0xef309c...262586 | 0xef309c...262586 |
0x2.2.2 DBALL 사건
DBALL 경우는 더 까다롭습니다. MetaSleuth를 사용하여 DBALL 소유자의 자금 흐름을 분석한 결과, 이 주소로의 DBALL 토큰 유입과 유출의 불균형을 관찰했으며, 이 트랜잭션의 자금 출처가 기록되지 않았습니다.
온체인에서 과거 상태를 쿼리하여, 최종적으로 이 트랜잭션 이전과 이후에 소유자의 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()]의 차감 중에 산술 언더플로우가 발생하여 0에서 type(uint256).max에 가깝게 전환됩니다. 이 미묘한 조작은 소유자가 잔액을 변경할 수 있게 하지만, 토큰 공급량의 불일치도 초래합니다.
이것이 우연한 실수일까요? 우리의 조사는 그것이 의도적인 러그 풀일 가능성이 더 높다고 제안합니다. 이유는 다음과 같이 요약됩니다:
-
소유자가
manualDevBurn함수에 1개의 토큰만 전달했지만, 30분 이내에 총 공급량에 해당하는 DBALL이 이 트랜잭션을 통해 연관 주소로 전송되었습니다. -
그 연관 주소는 즉시 PancakeSwap 페어에서 스왑하여 약 56 WBNB를 얻었습니다.
- 이 두 주소의 자금 흐름을 분석하면, 두 주소 모두 결국 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가 갑자기 상승하여 제외되지 않은 모든 사용자에게 잔액 손실이 발생합니다. 또한, 이론적으로 다음과 같은 가능성도 있습니다:

이로 인해 정밀도 손실로 인해 rate가 0이 되어 0으로 나누는 패닉이 발생할 수 있습니다.
0x4 완화 및 해결책
리플렉션 토큰 메커니즘은 추가 보상을 받기 위해 투자자들이 거래하는 대신 토큰을 보유하도록 인센티브를 제공함으로써 시장 안정성을 향상시키는 방법을 제공합니다. 그러나 r-공간과 t-공간 값의 혼동과 같은 새로운 보안 과제와 잠재적 위험도 도입합니다. 따라서, 블록체인 개발자와 투자자들이 메커니즘과 그 잠재적 위험에 대한 더 나은 이해를 얻고 해결책을 모색하는 것이 중요합니다.
BlockSec은 출시 전후 단계 모두를 위한 보안 서비스 및 제품을 제공합니다. 우리의 보안 감사 서비스는 코드 보안과 투명성을 보장하기 위해 철저한 검토를 수행합니다. 우리의 Phalcon 제품은 지속적인 보안 모니터링 및 공격 탐지 기능을 제공하여 운영자와 투자자가 프로젝트를 모니터링하고 보안 위험이 감지될 때 자동으로 조치를 취할 수 있게 합니다.
관련 읽기
BlockSec 소개
BlockSec은 풀스택 Web3 보안 서비스 제공업체입니다. 회사는 대중 채택을 촉진하기 위해 새롭게 부상하는 Web3 세계의 보안과 사용성을 향상시키는 데 전념하고 있습니다. 이를 위해 BlockSec은 스마트 컨트랙트 및 EVM 체인 보안 감사 서비스, 보안 개발 및 위협 차단을 위한 Phalcon 플랫폼, 자금 추적 및 조사를 위한 MetaSleuth 플랫폼, 그리고 Web3 빌더가 암호화폐 세계를 효율적으로 탐색할 수 있는 MetaSuites 확장 프로그램을 제공합니다.
현재까지 회사는 Uniswap Foundation, Compound, Forta, PancakeSwap 등 300개 이상의 클라이언트를 서비스했으며, Matrix Partners, Vitalbridge Capital, Fenbushi Capital을 포함한 저명한 투자자들로부터 두 차례의 자금 조달 라운드에서 수천만 달러를 받았습니다.
-
웹사이트: https://blocksec.com/
-
이메일: [email protected]
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



