Back to Blog

Reflection Token'lar Üzerine Düşünceler: Bir Güvenlik Perspektifi

Phalcon
6 Haziran 2024
24 min read

14 Haziran 2024'te güncellendi: Bir topluluk üyesi bu blogu dikkatlice inceledi ve önceki kategorizasyonumuzda ele alınmayan yeni bir biçim olan ADU olayı hakkında bilgi verdi. Teşekkürler, ve tüm içgörülü geri bildirimler her zaman memnuniyetle karşılanır!

Piyasa istikrarını artırmak için, yansıma tokenleri (aka ödül tokenleri), yatırımcılara gelir elde etmek için ek bir yol sunmak amacıyla tasarlanmıştır. Bu, yatırımcıları tokenlerini işlem yapmak yerine elde tutmaya teşvik eder. Kötü şöhretli 2021 meme coin sezonunda, yansıma tokenleri, DxSale gibi platformlarda piyasaya sürüldükten sonra hızla piyasanın dikkatini çekerek vazgeçilmez bir mekanizma haline geldi (örneğin, SafeMoon V1).

Çılgınlığın azalmasına ve piyasanın 2023'te soğumasına rağmen, sistemimiz bu tür token mekanizmalarını istismar eden on binlerce hack olayını gerçek dünyada tespit etti. Bu avcı tarzı saldırılar, diğer DeFi saldırı türleriyle karşılaştırıldığında miktar açısından nispeten küçük ölçekli olmasına rağmen, kullanıcı varlıklarında göz ardı edilemeyecek kayıplara neden oldu.

Bu blogda, temel odağımız araştırmamızdan elde edilen güvenlikle ilgili içgörüleri paylaşmaktır. Özellikle, önce yansıma tokeninin mekanizmasına kısa bir giriş yapacağız. Ardından, yansıma tokeni mekanizmasını istismar eden olaylara odaklanarak yansıma tokenleriyle ilgili güvenlik olaylarını gözden geçireceğiz. Daha sonra, teorik olarak potansiyel bir güvenlik sorununu tartışacağız. Son olarak, azaltma ve çözümler hakkında bazı düşüncelerimizi paylaşacağız.

0x1 Yansıma Tokeninin Mekanizması

Bildiğimiz kadarıyla, bu mekanizma ilk olarak Reflect Finance tarafından tanıtıldı ve işlem tutarının belirli bir yüzdesini, işlemsel olmayan bir şekilde tüm token sahiplerine ücret olarak dağıtmak üzere tasarlandı. Mart 2021'de, ünlü SafeMoon V1, BNB zincirinde yayınlandı ve bu, yansıma tokenini daha da popülerleştirdi.

0x1.1 Temel Kavramlar

Ayrıntılara girmeden önce, daha iyi anlaşılmasını sağlamak için bazı temel kavramların tanıtılması gerekmektedir.

İki tür alan vardır: r-alanı ve t-alanı, sırasıyla yansıtılmış alan ve gerçek alan olarak okunur. İki alanın kripto para birimleri, göreceli dolaşım hacmine dayalı değişim oranlarına sahiptir. Ayrıca, r-alanındaki para birimi deflasyonisttir, yani her işlemde belirli bir yüzde yakılır ve sonuç olarak, yakılan miktar dolaşım hacminden düşülür.

Aşağıdaki şekilde gösterildiği gibi, Alice, Bob ve Eve'nin her ikisinde de işlem yapabildiklerini düşünelim. Eğer Alice ve Bob t-alanında karşılıklı transferler yaparsa, Eve herhangi bir ödül almayacaktır. Ancak, üçü de önce tokenlerini r-alanına dönüştürüp sonra Alice ve Bob birbirlerine transfer yaparsa, Eve sonunda tokenlerini t-alanına geri dönüştürerek pasif gelir elde edecektir. Bu, yansıma tokeni mekanizmasının temel fikridir.

Tokenin likidite sağlama havuzu gibi tüm hesapların r-alanında işlem yapamayacağını, yani belirli hesapların r-alanından hariç tutulması gerektiğini unutmayın.

0x1.2 Sözleşme Düzeyinde Açıklama

Şimdi bu mekanizmayı incelemek için Reflect Finance'in REFLECT sözleşmesine derinlemesine bakalım.

Bu sözleşme önce hesap yönetimi için birkaç değişken tanımlar:

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

Ardından, sözleşmenin temel sabitlerini tanımlar. _rTotal'ın _tTotal'ın (yani totalSupply fonksiyonunun dönüş değeri olarak kullanılan tokenin totalSupply'ı) belirli bir katına ayarlandığı gözlemlenebilir:

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

İşlevsellik ve kullanıcı etkileşimi açısından, bu sözleşmenin fonksiyonları aşağıdaki üç kategoriye ayrılır: bakiye sorgulama ve token transferi, bunun yanı sıra ayırt edici bir reflect fonksiyonu. İlk ikisi ERC-20 standardıyla uyumludur, ancak iç mantık diğer ERC-20 tokenlerinden farklıdır. Bu fonksiyonların her biri aşağıda ayrıntılı olarak ele alınacaktır.

0x1.2.1 Bakiye sorgulama fonksiyonları

Bakiye hesaplaması hariç tutulmuş ve hariç tutulmamış kullanıcılar için farklıdır:

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

Bu, aşağıdaki formülle ifade edilebilir:

Yukarıdaki formüldeki oran, aslında sözleşme içindeki _getCurrentSupply fonksiyonunun dönüş değerinden hesaplanan _getRate fonksiyonu çağrılarak hesaplanır.

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

Yukarıdaki kod parçasından ilgili formülü türetmek zor değildir:

0x1.2.2 Token transferi fonksiyonları

Genel olarak, varlıkları transfer etmek için dört senaryo vardır, aşağıdaki gibi:

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

Hariç tutulmuş hesaplar için, hem _rOwned hem de _tOwned ilgili alandan eklenmeli veya çıkarılmalıdır. Hariç tutulmamış hesaplar için, yalnızca _rOwned dikkate alınmalıdır. Örneğin, aşağıdaki kod parçası, sender'ın hariç tutulmamış bir hesap ve recipient'ın hariç tutulmuş bir hesap olduğu, r-alanından t-alanına varlık transferinin uygulanmasını gösterir.

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 fonksiyonu, her iki alan için de ilgili amount, transferAmount ve fee'yi hesaplar (yani, 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 fonksiyonu, ücreti r-alanında yansıtır. Özellikle, _rTotal, r-alanındaki ücret (yani, rFee) kadar azaltılır ve bu da oranı düşürür. balanceOf(user) hesaplama formülüne göre, ücretler bu şekilde hariç tutulmamış tüm token sahiplerine yansıtılır.

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

0x1.2.3 reflect fonksiyonu

Yansıma tokeni mekanizmasının transfer süreci sırasında pasif olarak tetiklenmesine ek olarak, kullanıcılar bu mekanizmayı başlatmak için reflect fonksiyonunu aktif olarak çağırabilirler. Özellikle, birisi bu fonksiyonu çağırmak için sahip olduğu tokenleri tüketirse, rSupply azaldıkça oran düşecek ve böylece diğer token sahiplerine fayda sağlayacaktır. Başka bir deyişle, başkaları uğruna kendini feda etmek. Bu sayede, proje yöneticileri bu fonksiyonun kullanımıyla token sahiplerini teşvik edebilirler.

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

Yukarıda verilen kodlar mekanizmanın çekirdeğini oluşturur. Farklı tokenler, uygulamalarını özelleştirmek için belirli ek fonksiyonlar içerebilir. Örneğin, bazıları büyük yatırımcılar (whale) tokenlerini satmaya karar verdiğinde izdihamı önlemek için "swap and liquify" fonksiyonunu güçlendirmek üzere işlem ücretlerini kullanabilir.

0x2 Soyulan Yansıma Tokenlerinin Otopsisi

Daha önce belirtildiği gibi, temel odağımız yansıma tokeni mekanizmasını istismar eden saldırılardır. Bu nedenle, SafeMoon V2 saldırısı (yaygın bir ERC20 kamuya açık yakma sorunu) ve yakın zamandaki ZongZi saldırısı (spot fiyatı istismar eden eski usul fiyat manipülasyonuyla ilgili) gibi bu mekanizmayla ilgisi olmayan olaylar ele alınmamıştır.

Kök nedenlerini ortaya çıkarmak için bu saldırılar üzerinde derinlemesine bir analiz yaptık. Tüm bu olayların bir listesi için buraya bakabilirsiniz. Çoğunun ya kod açıkları ya da uygunsuz yönetimsel işlemlerden kaynaklanan normal güvenlik olayları olduğunu bulduk. Ancak, birkaçı oldukça şüpheli (örneğin, bir arka kapının varlığı), bunlara anormal güvenlik olayları diyoruz. Aşağıdaki alt bölümlerde, önce normal güvenlik olaylarını tanıtacağız ve ardından anormal olanları ayrıntılı olarak inceleyeceğiz.

0x2.1 Normal güvenlik olayları

Araştırmamız, bu olayların iki tür sorundan, yani kod düzeyinde sorunlar ve işlem düzeyinde sorunlardan, ayrı ayrı veya birlikte kaynaklandığını göstermektedir.

  1. Kod düzeyinde sorunlar. Bu, muhtemelen geliştiricilerin yansıma tokeni mekanizmasını tam olarak anlamamasından kaynaklanan sözleşmenin kötü uygulanmasından kaynaklanır ve tokenlerin gerçek arzı ile kaydedilen totalSupply değeri arasında tutarsızlığa yol açarak, oranı manipüle etmek için kullanılabilir:

    • 1.1 Sıfır maliyetli yakma

    • 1.2 Token transferleri sırasında rSupply'dan fazladan düşüm

    • 1.3 r-alanı ve t-alanı değerleri arasındaki karışıklık (kâr etmek için hassasiyet kaybıyla birlikte)

  2. İşlem düzeyinde sorunlar. Bu, yöneticiler tarafından uygunsuz işlemden kaynaklanır. Özellikle, bu olaylarda, bu doğru şekilde hariç tutulmayan AMM çift adreslerinin uygunsuz yapılandırılmasını ifade eder.

Listelenen kod düzeyindeki sorunların HEPSİNİN gerçek arz ile totalSupply arasında tutarsızlıklara yol açarak sözleşmeleri savunmasız hale getirebileceğini belirtmekte fayda var. Ancak, bu, bu açıkların istismar edilebilir olduğu veya daha doğrusu istismar edilmeye değecek kadar kârlı olduğu anlamına gelmez, çünkü saldırganın oranı manipüle etmesi için bir maliyet olabilir. Basitlik için, aşağıda "istismar edilebilir" terimini "istismar edilmeye değecek kadar kârlı" anlamında kullanacağız. Sonuç olarak, bazı senaryolarda işlem düzeyinde bir sorun, bu açıkları istismar edilebilir hale getirmek için gereklidir.

Özellikle, sorun 1.1 doğrudan istismar edilebilirken, sorun 1.2 ve 1.3'ün istismar edilebilir hale gelmesi için sorun 2 ile birleştirilmesi gerekir. Bu gözlemlere dayanarak, tartışılan olaylar Tip-I ve Tip-II olmak üzere iki türe ayrılabilir. Aşağıda ilgili verileri özetleyen bir tablo bulunmaktadır:

Tip Olay(lar) Kök Neden(ler) # (%)
I CATOSHI Yalnızca kod düzeyinde sorun (1.1) 1 (0.79%)
II (a) BEVO, FETA, ADU Her ikisinin kombinasyonu (1.2 ve 2) 3 (2.38%)
II (b) SHEEP, ve 120+ Diğerleri Her ikisinin kombinasyonu (1.3 ve 2) 122 (96.83%)

Tablo, Tip-II olaylarının önemli bir oranı oluşturduğunu göstermektedir. Özellikle, Tip-II içinde 2 varyasyon vardır: Tip-II-a (yani, sorun 1.2 ile sorun 2) ve Tip-II-b (yani, sorun 1.3 ile sorun 2). Ayrıca, Tip-II-b kategorisindeki SHEEP benzeri olaylar için, açıklar, saldırganların (örneğin, bu adresin) benzer şekilde savunmasız sözleşmeleri tespit etmek için otomatik yöntemler kullanıyor olabileceğini göstermektedir. Belirli ayrıntılar sonraki alt bölümlerde incelenecektir.

0x2.1.1 Tip-I: Yalnızca Kod Düzeyinde Sorun (Sorun 1.1)

Yalnızca bir olay Tip-I'e ait, yani toplam arzı etkileyen sıfır maliyetli bir yakma olan CATOSHI olayı.

Önce CATOSHI sözleşmesindeki burnOf fonksiyonuna bir göz atalım:

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

Açıkça, bu fonksiyon tarafından yakılan miktar çağırandan (yani, msg.sender) düşülmez. Ancak, _rOwned[msg.sender], rAmount kadar azaltılmalı ve eğer hesap hariç tutulmuşsa, _tOwned[msg.sender] da tAmount kadar azaltılmalıdır.

Bu gözden kaçırma nedeniyle, saldırganlar önce sıfır maliyetle büyük miktarda token yakabilir ve ardından sözleşmenin reflect fonksiyonunu çağırabilir. Hem _tTotal hem de _rTotal orantılı olarak önemli ölçüde azaldığından:

Oran, reflect fonksiyonunu çağırarak kolayca aşağı yönde manipüle edilebilir ve bu da balanceOf(saldırgan)'ın önemli ölçüde artmasına neden olur. Bu, saldırganların şişirilmiş bakiyeden kâr etmesine olanak tanır.

Neden mi? Saldırganın yeni bakiyesinin aşağıdaki gibi hesaplandığına dikkat edin:

balanceOf(saldırgan) ve balanceOf(saldırgan)' arasındaki oran:

Şu şekilde

Buradan

Bu, saldırganın daha fazla token elde ettiği ve bunları değerli tokenlerle (bu durumda WETH) takas ederek kâr elde edebildiği anlamına gelir.

0x2.1.2 Tip-II-a: Sorun 1.2 ve Sorun 2'nin Kombinasyonu

Tip-II-a olayları iki sorunun kombinasyonunu içerir:

  • Sorun 1.2: Token transferleri sırasında rSupply'dan fazladan düşüm.
  • Sorun 2: AMM çifti hariç tutulmamıştır.

Tip-II-a'da, sorun 1.2'deki açık biçimlerine göre iki alt kategoriye daha ayrılabilen üç saldırı olayı vardır, aşağıdaki gibi:

1. _reflectFee fonksiyonunda fazladan yansıtma

Bu alt kategoriye iki olay ait, yani BEVO olayı ve FETA olayı. Aşağıda, açıklama için BEVO sözleşmesini kullanacağız.

'Token Transferi Fonksiyonları' bölümünde (0x1.2.2) tanıtıldığı gibi, her token transferi işlem ücretinin bir kısmının yansıtılmasını tetikler. BEVO'da, ücretler orijinal olana ek olarak burn ve charity olmak üzere iki ek parçaya bölünür.

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 fonksiyonunda gösterildiği gibi, charity hesabının hariç tutulduğuna, yani bu hesaba gönderilen miktarın yakıldığına dikkat edin.

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

Yukarıdaki kod parçasından, charity payını yansıtmak ve yakmak için iki yer olduğunu görebiliriz, bu da transferler sırasında gerçek token arzının totalSupply ile tutarsız hale gelmesine neden olur. Daha fazla token transfer edildikçe, rSupply'nin değeri, ek azalma nedeniyle havuzdaki token arzından daha az olacaktır.

Salt teorik açıklama biraz soyut olabilir, bu yüzden süreci netleştirmek için bir örnek kullanalım. Alice'in Bob'a 10 token transfer etmek istediğini ve aşağıdaki gibi 3 tokenin düşüldüğünü varsayalım: 1 tanesi ücret için, 1 tanesi yakma için ve 1 tanesi charity için. Charity payı hem yansıtıldığı hem de yakıldığı için, gerçek dağılım 2 token yansıtılmış (1 ücret + 1 charity) ve 2 token yakılmış (1 yakma + 1 charity) şeklindedir. Bob'a transfer edilecek kalan 7 token ile birlikte, bu süreçte toplam 11 token yer alır, bu da hatalıdır.

Ancak bu tutarsızlık neden kâr elde etmek için istismar edilebilir? Aşağıda, sonuçları türetmek için matematiksel sihirli değneğimizi sallayacağız.

Önceden havuzdan (yani PancakeSwap çiftinden) bazı tokenler edindiğimizi varsayalım, r-alanında rAmount ve t-alanında tAmount olarak gösterilsin. Havuz hariç tutulmadığından, _rOwned[pair]'i rReserve olarak gösterelim, t-alanındaki karşılık gelen değer de tReserve olarak gösterilsin. O zaman şunu elde ederiz:

Ek azalma nedeniyle, rSupply şimdi havuzdaki token arzından daha azdır:

'Bakiye Sorgulama Fonksiyonları' bölümünü (0x1.2.1) hatırlayın, mevcut oran aşağıdaki formül kullanılarak hesaplanabilir:

Bu noktada, eğer elimizdeki tokenleri reflect fonksiyonu (bu sözleşmede deliver olarak yeniden adlandırılmıştır) aracılığıyla yansıtırsak, oran, oran' haline gelir:

Şu şekilde

Sonrasında şunu elde ederiz

Formül 1, 3 ve 6'yı birleştirerek, aşağıdaki eşitsizliği türetebiliriz:

Bu, havuzdan doğrudan hasat edebileceğimiz token sayısının (skim fonksiyonu aracılığıyla) teslim ettiğimizden daha büyük olduğu anlamına gelir; bu da reflect fonksiyonunu çağırmanın maliyetinin karşılanabilmesi nedeniyle kârlı hale getirir. Bundan sonra, saldırgan hasat edilen tokenleri kâr elde etmek için değerli tokenlerle (bu durumda WBNB) takas edebilir.

BEVO sözleşmesinin, saldırıda istismar edilmemiş olan sorun 1.3'e karşı da savunmasız olduğuna dikkat edin.

2. _getRValues fonksiyonundaki hatalı rTransferAmount hesaplaması

Yalnızca bir olay bu forma ait, yani ADU olayı. Önce aşağıdaki kod parçasına bir göz atalım.

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

Görebiliriz ki, hem vergi ücreti hem de ekip ücreti transferler sırasında düşülmelidir. Ancak, _getTvalues fonksiyonunda, tTransferAmount'tan hem tFee hem de tTeam çıkarılırken, _getRValues fonksiyonunda yalnızca rFee çıkarılır. Bu tutarsızlık, daha önce bahsedilen tutarsızlık sorununa yol açar ve daha fazla token transferi gerçekleştikçe kötüleşir.

Çift, token içinde de hariç tutulmadığından, bu token istismar edilebilir. Özellikle, bir saldırgan, deliver fonksiyonunu çağırdıktan sonra çiftin skim fonksiyonu aracılığıyla daha fazla ADU tokeni hasat etmek için benzer BEVO istismarlarını kullanabilir.

Ancak, o zamanki zincir üstü duruma göre, saldırganın hasat edilen ADU tokenlerini kâr etmek için WBNB ile takas etmesi mümkün değildir (tokenFromReflection fonksiyonundaki require ifadesi nedeniyle). Bu nedenle, saldırganın kâr etmek için burada ayrıntılandırılmayacak daha karmaşık bir istismar stratejisi kullanması gerekecektir.

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 Tip-II-b: Sorun 1.3 ve Sorun 2'nin Kombinasyonu

Tip-II-b olayları iki sorunun kombinasyonunu içerir:

  • Sorun 1.3: r-alanı ve t-alanı değerleri arasındaki karışıklık (kâr etmek için hassasiyet kaybının da mevcut olması gerektiğine dikkat edin).
  • Sorun 2: AMM çifti hariç tutulmamıştır.

Sorun 1.3, dahili _burn fonksiyonunun uygulanması sırasında r-alanı ve t-alanı arasındaki değerlerin yanlış işlenmesinden kaynaklanmaktadır.

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

Sözleşmenin temel sabitlerini göz önünde bulundurarak, r-alanı değeri tipik olarak t-alanı değerinin büyük bir katıdır. Bu nedenle, tSupply ile aynı büyüklük sırasına sahip bir _value ile burn fonksiyonunun çağrılması, oranı önemli ölçüde şişirecektir.

Ancak, Tip-I durumlarının aksine, çağıran kendi bakiyesini değiştirmeden token yakamaz. Başka bir deyişle, saldırganın daha fazla token hasat etmesi zor, hatta imkansızdır. O halde, Tip-II-b durumları nasıl istismar edilebilir olabilir?

Örnek olarak SHEEP token olayını ele alalım. Saldırganın elinde bulunan SHEEP tokenlerinin değeri şu şekilde gösterilebilir:

SHEEP'in fiyatı, PancakeSwap çiftindeki spot fiyat ile ifade edilebilir, aşağıdaki gibi hesaplanır:

O zaman Değer şu şekilde daha da ifade edilebilir:

Saldırgan daha sonra burn fonksiyonunu tekrar tekrar yürütür ve sonunda çifti senkronize eder. Ne saldırgan ne de çift hariç tutulmadığından, yukarıda bahsettiğimiz oran şişmesi nedeniyle bakiyeleri düşer. Böylece oran şu şekilde ayarlanır:

Önceki tanımlara dayanarak, bu oranları daha da şu şekilde ifade edebiliriz:

Burada X, yakılan _value'nun toplamını temsil eder.

İşte sihir burada: 8 ve 9'u daha fazla basitleştirirsek, ikinci oran ilkinden açıkça daha küçüktür, bu da bizi düşündürür çünkü bu durumda kâr negatif bir değer olacaktır:

Aslında, saldırgan yansıma tokenindeki bir hassasiyet kaybı sorunundan yararlandı. Hariç tutulmamış kullanıcılar için, sağladığımız formüle göre, bakiye hesaplaması aslında tokenFromReflection fonksiyonunda aşağı yuvarlanır. Bu nedenle, balanceOf sorgusunun dönüş değeri teorik değerinden daha küçük olabilir. Yani, bu sorunu göz önünde bulundurursak, oran', oran'dan daha büyük olabilir.

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

Saldırı işlemini hata ayıklayarak, saldırganın ve çiftin bu manipülasyonlardan önceki ve sonraki teorik bakiyesini hesaplayabiliriz. Hesaplamalarımızın sonuçları aşağıdaki tabloda özetlenmiştir:

Tablodaki Δ son derece küçük bir değerdir, 1'den çok daha azdır.

Çiftin sync fonksiyonu içindeki izi analiz ederek, saldırganın ve çiftin teorik bakiyelerinin aslında sırasıyla 27.523 ve 2.972 olduğunu hesaplayabiliriz, bu da 9.26'lık bir orana karşılık gelir. Ancak, hassasiyet kaybı nedeniyle, bakiyeler sırasıyla 27 ve 2'ye yuvarlanır ve oranı 13.50'ye şişirir. Sonuç olarak, Kâr pozitif bir değer haline gelir.

Son olarak, saldırgan ters bir swap gerçekleştirerek kâr edebilir.

0x2.2 Anormal güvenlik olayları

Bu alt bölümde, FDP ve DBALL tokenlerinin incelemesinden elde ettiğimiz bulguları paylaşacağız. Analizimiz, hem FDP hem de DBALL tokenlerinin yöneticilerinin, etkili bir şekilde arka kapı olarak işlev gören sorunlu ayrıcalıklı fonksiyonları çağırdığını göstermektedir; bu da projeleri risk altına sokmuş ve nihayetinde saldırılara yol açmıştır. Özellikle, DBALL projesinde, token sahibinin bir dizi şüpheli işlemini tespit ettik, bu da bunun bir rug pull olarak değerlendirilmesi için açık kanıt sağlamaktadır.

Bu iki tokeni hedefleyen istismarlar, 0x2.1.2 altında açıklanan 'Tip-II-a: Sorun 1.2 ve Sorun 2'nin Kombinasyonu' bölümünde tartışılanlara yakından benzemektedir. Ancak, gerçek token arzının neden totalSupply'dan sapmış olabileceği nedenlerini analiz ederken, bazı şüpheli faaliyetler ortaya çıkmaktadır.

0x2.2.1 FDP olayı

FDP durumunda gerçek token arzı ile totalSupply arasındaki tutarsızlık, yalnızca sözleşme sahibi tarafından çağrılabilen ayrıcalıklı bir fonksiyon olan transferOwnership fonksiyonunun çağrılmasından kaynaklanmaktadır. Adından da anlaşılacağı gibi, bu fonksiyonun sözleşmenin sahipliğini değiştirmesi beklenir. Ancak, FDP sözleşmesinde, bu fonksiyonun sahiplik transferiyle hiçbir ilgisi yoktur. Bunun yerine, totalSupply'ı değiştirmeden _rOwned[newOwner]'ı artırır. Bu, açıkça normal token basma sürecinin tasarım ilkelerini ihlal etmektedir.

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

Bu fonksiyonu çağıran işlemler aşağıdaki tabloda özetlenmiştir:

0x2.2.2 DBALL olayı

DBALL durumunda işler daha karmaşık hale gelir. DBALL sahibinin fon akışını analiz etmek için MetaSleuth kullanarak, bu işlem için fon kaynağının kaydedilmediği bu adrese DBALL token giriş ve çıkışında bir dengesizlik gözlemledik.

Zincir üzerindeki geçmiş durumları sorgulayarak, sonunda sahibinin DBALL bakiyesinin bu işlemden önce ve sonra değiştiğini tespit ettik. Sahibinin, t-alanında 1 token yakmak için ayrıcalıklı manualDevBurn fonksiyonunu çağırdığını gözlemleyebiliriz. Bu fonksiyonun uygulaması şu şekildedir:

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

İlk bakışta, her şey normal görünüyor. Ancak, sözleşmenin 0.8'den daha düşük bir derleyici sürümü belirtmesi nedeniyle, _rOwned[_msgSender()]'ın çıkarılması sırasında bir aritmetik taşma (underflow) meydana gelir ve 0'dan neredeyse type(uint256).max'e geçiş yapar. Bu ince manipülasyon, sahibinin bakiyesini değiştirmesine olanak tanır, ancak aynı zamanda token arzında tutarsızlığa da yol açar.

Bu sadece kazara bir hata mı? Araştırmamız, bunun kasıtlı bir rug pull olma ihtimalinin daha yüksek olduğunu göstermektedir. Nedenler aşağıda özetlenmiştir:

  1. Sahibi, manualDevBurn fonksiyonuna yalnızca 1 token geçirdi, ancak yarım saat içinde, toplam arza eşit miktarda DBALL, bu işlem aracılığıyla ilişkili bir adrese transfer edildi.

  2. Bu ilişkili adres, PancakeSwap çiftinde hemen swap yaptı ve yaklaşık 56 WBNB elde etti.

  1. Bu iki adresin fon akışlarını analiz etmek, her ikisinin de sonunda BNB'yi Tornado.Cash aracılığıyla transfer ettiğini ortaya koymaktadır.

0x3 Oran Hesaplamasında Potansiyel Bir Sorun

Daha önce tartıştığımız olayların ötesinde, teorik olarak, hariç tutulmamış kullanıcıların bakiye hesaplamasında daha fazla tartışmayı hak eden potansiyel bir sorunun bulunduğunu da bulduk. Bu sorun oran hesaplaması sırasında ortaya çıkabilir.

_getCurrentSupply fonksiyonuna bir göz atalım. Bu fonksiyonda, sondaki if ifadesi, rSupply'nin başlangıç oranından (yani, _rTotal / _tTotal) daha az olup olmadığını belirler.

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

Sözleşme dağıtımından proje lansmanına kadar olan yaşam döngüsü boyunca, bu ifade doğruysa, hariç tutulmamış tüm kullanıcıların bakiyeleri sıfır olur. Ancak, proje bir kez başlatıldıktan ve işlemler başladıktan sonra, if ifadesinin asıl amacı kaybolur.

Yansıma tokeni mekanizması nedeniyle rSupply azalacağından, oran da buna göre azalacaktır. Belirli bir işlemden sonra rSupply, başlangıç oranının altına düşerse, mevcut oran sıçrayacak ve hariç tutulmamış tüm kullanıcılar için bakiye kayıplarına neden olacaktır. Ayrıca, teorik olarak şunun mümkün olduğu belirtilmelidir:

Bu, hassasiyet kaybı nedeniyle oranın sıfır olmasına neden olur ve potansiyel olarak sıfıra bölme panik hatasını tetikleyebilir.

0x4 Azaltma ve Çözümler

Yansıma tokeni mekanizması, yatırımcıları işlem yapmak yerine ek ödüller almak için tokenlerini elde tutmaya teşvik ederek piyasa istikrarını artırmanın bir yolunu sunar. Ancak, aynı zamanda r-alanı ve t-alanı değerleri arasındaki karışıklık gibi yeni güvenlik zorlukları ve potansiyel riskler de getirir. Bu nedenle, blockchain geliştiricileri ve yatırımcıların mekanizmayı ve potansiyel risklerini daha iyi anlaması ve çözümler araması önemlidir.

BlockSec, hem lansman öncesi hem de sonrası aşamalar için güvenlik hizmetleri ve ürünleri sunar. Güvenlik denetim hizmetlerimiz, kod güvenliğini ve şeffaflığını sağlamak için kapsamlı incelemeler yürütür. Phalcon ürünümüz, operatörlerin ve yatırımcıların projeleri izlemesini ve güvenlik riskleri tespit edildiğinde otomatik önlemler almasını sağlayan sürekli güvenlik izleme ve saldırı tespit yetenekleri sunar.

İlgili Okumalar


BlockSec Hakkında

BlockSec, uçtan uca bir Web3 güvenlik hizmeti sağlayıcısıdır. Şirket, kitlesel benimsenmeyi kolaylaştırmak amacıyla, gelişmekte olan Web3 dünyası için güvenlik ve kullanılabilirliği artırmaya kararlıdır. Bu amaçla, BlockSec akıllı sözleşme ve EVM zinciri güvenlik denetim hizmetleri, güvenlik geliştirme ve tehditleri proaktif olarak engellemek için Phalcon platformu, fon takibi ve soruşturması için MetaSleuth platformu ve web3 kurucularının kripto dünyasında verimli bir şekilde gezinmesi için MetaSuites uzantısını sunmaktadır.

Bugüne kadar şirket, Uniswap Foundation, Compound, Forta ve PancakeSwap gibi 1.000'den fazla müşteriye hizmet vermiş ve Matrix Partners, Vitalbridge Capital ve Fenbushi Capital gibi seçkin yatırımcılardan iki tur finansmanla on milyonlarca ABD doları almıştır.