Back to Blog

Yansıma Tokenlarını Yeniden Değerlendirmek: Bir Güvenlik Perspektifi

Phalcon
June 6, 2024
24 min read

14 Haziran 2024'te güncellendi: Bir topluluk üyesi bu blogu dikkatle inceledi ve önceki kategorilendirmemizde yer almayan yeni bir form olan ADU olayı hakkında bilgi sağladı. Teşekkürler ve tüm anlayışlı geri bildirimler memnuniyetle karşılanır!

Piyasa istikrarını artırmak için yansıma token'ları (ödül token'ları olarak da bilinir), yatırımcılara ek bir gelir elde etme yolu sunmak amacıyla tasarlanmıştır. Bu, yatırımcıları token'larını takas etmek yerine ellerinde tutmaya teşvik eder. 2021'in kötü şöhretli meme coin sezonunda, yansıma token'ları vazgeçilmez bir mekanizma haline geldi ve DxSale gibi platformlarda (örneğin, SafeMoon V1) piyasaya sürüldükten sonra piyasanın dikkatini hızla çekti.

2023'te çılgınlık azalıp piyasa soğumasına rağmen, sistemimiz vahşi doğada bu tür token mekanizmalarını istismar eden onlarca binlerce hack olayı tespit etti. Biçici tarzı bu saldırılar, diğer DeFi saldırı türleriyle karşılaştırıldığında nispeten küçük ölçekli olmasına karşın, kullanıcı varlıkları üzerinde göz ardı edilemeyecek kayıplara neden oldu.

Bu blogda, temel odak noktamız araştırmamızdan elde edilen güvenlikle ilgili içgörüleri paylaşmaktır. Özellikle, önce yansıma token mekanizmasına kısa bir giriş yapacağız. Ardından, yansıma token'larıyla ilgili güvenlik olaylarını, özellikle yansıma token mekanizmasını istismar edenleri inceleyeceğiz. Daha sonra, teorik olarak potansiyel bir güvenlik sorununu tartışacağız. Son olarak, azaltma ve çözümler hakkındaki düşüncelerimizi paylaşacağız.

0x1 Yansıma Token Mekanizması

Bilgimize göre, bu mekanizma ilk olarak Reflect Finance tarafından tanıtılmış olup, işlem tutarının belirli bir yüzdesini tüm token sahiplerine işlem dışı bir şekilde ücret olarak dağıtmak için tasarlanmıştır. Mart 2021'de, ünlü SafeMoon V1 BNB zincirinde yayınlandı ve bu durum yansıma token'ının daha da popülerleşmesini sağladı.

0x1.1 Temel Kavramlar

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

İki tür uzay vardır: r-uzayı ve t-uzayı, sırasıyla yansıtılmış uzay ve gerçek uzay olarak okunur. İki uzayın kripto para birimleri, göreli dolaşım hacmine dayalı döviz kurlarına sahiptir. Ayrıca, r-uzayındaki para birimi deflasyonludur, yani her işlemde belirli bir yüzde yakılacak ve sonuç olarak yakılan miktar dolaşım hacminden düşülecektir.

Alice, Bob ve Eve'nin aşağıdaki şekilde gösterildiği gibi her iki uzayda da işlem yapabildiğini düşünün. Alice ve Bob t-uzayında ikili transferler gerçekleştirirse, Eve herhangi bir ödül almaz. Ancak, üçü de önce token'larını r-uzayına dönüştürür ve ardından Alice ile Bob birbirlerine transfer yaparsa, Eve sonunda token'larını t-uzayına geri dönüştürerek pasif gelir elde eder. İşte yansıma token mekanizmasının temel fikri budur.

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

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

Şimdi bu mekanizmayı keşfetmek için Reflect Finance'ın REFLECT sözleşmesine dalalım.

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

mapping (address => uint256) private _rOwned; // kullanıcının elindeki yansıtılmış token
mapping (address => uint256) private _tOwned; // kullanıcının elindeki gerçek token
mapping (address => mapping (address => uint256)) private _allowances;

mapping (address => bool) private _isExcluded; // kullanıcının r-uzayından hariç tutulup tutulmadığı
address[] private _excluded; // r-uzayından hariç tutulan hesaplar

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

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 işlevleri aşağıdaki üç kategoriye ayrılır: bakiye sorgulama ve token transferi ile birlikte ayırt edici bir yansıma işlevi. İlk ikisi ERC-20 standardıyla uyumludur, ancak iç mantık diğer ERC-20 token'larından farklıdır. Bu işlevlerin her biri aşağıda ayrıntılı olarak açıklanacaktır.

0x1.2.1 Bakiye sorgulama işlevleri

Bakiye hesaplaması, hariç tutulan ve hariç tutulmayan kullanıcılar için farklılık gösterir:

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, "Miktar toplam yansımalardan az olmalıdır");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

Aşağıdaki formülle ifade edilebilir:

Yukarıdaki formüldeki oran, sözleşme içindeki _getCurrentSupply işlevinin dönüş değerinden hesaplanan _getRate işlevi ç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çacığından karşılık gelen formülü türetmek zor değildir:

0x1.2.2 Token transferi için işlevler

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

function _transfer(address sender, address recipient, uint256 amount) private {
    require(sender != address(0), "ERC20: sıfır adresten transfer");
    require(recipient != address(0), "ERC20: sıfır adrese transfer");
    require(amount > 0, "Transfer miktarı sıfırdan büyük olmalıdır");
    if (_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferFromExcluded(sender, recipient, amount); // t-uzayı -> r-uzayı
    } else if (!_isExcluded[sender] && _isExcluded[recipient]) {
        _transferToExcluded(sender, recipient, amount); // r-uzayı -> t-uzayı
    } else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferStandard(sender, recipient, amount); // r-uzayı -> r-uzayı
    } else if (_isExcluded[sender] && _isExcluded[recipient]) {
        _transferBothExcluded(sender, recipient, amount); // t-uzayı -> t-uzayı
    } else {
        _transferStandard(sender, recipient, amount); // r-uzayı -> r-uzayı
    }
}

Hariç tutulan hesaplar için, hem _rOwned hem de _tOwned ilgili uzaydan eklenip çıkarılmalıdır. Hariç tutulmayan hesaplar için yalnızca _rOwned dikkate alınması gerekir. Örneğin, aşağıdaki kod parçacığı, gönderenin hariç tutulmayan bir hesap ve alıcının hariç tutulan bir hesap olduğu r-uzayından t-uzayına varlık transferinin uygulamasını göstermektedir.

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 işlevi, her iki uzay için karşılık gelen miktarı, transferMiktarını ve ücreti hesaplar (yani miktar = transferMiktarı + ücret).

    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 işlevi, ücreti r-uzayında yansıtır. Özellikle, _rTotal r-uzayındaki ücret kadar (yani rFee) azaltılır ve bu da oranı düşürür. balanceOf(kullanıcı) hesaplama formülüne göre, ücretler bu şekilde hariç tutulmayan 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 işlevi

Transfer sürecinde yansıma token mekanizmasının pasif olarak tetiklenmesine ek olarak, kullanıcılar bu mekanizmayı başlatmak için reflect işlevini aktif olarak çağırabilirler. Özellikle, eğer biri elindeki token'ları bu işlevi çağırmak için kullanırsa, rSupply azaldıkça oran azalacak ve böylece diğer token sahiplerine fayda sağlayacaktır. Başka bir deyişle, başkaları için kendini feda etmek. Bunu yaparak, proje yöneticileri bu işlevin kullanımıyla token sahiplerini teşvik edebilir.

function reflect(uint256 tAmount) public {
    address sender = _msgSender();
    require(!_isExcluded[sender], "Hariç tutulan adresler bu işlevi çağıramaz");
    (uint256 rAmount,,,,,,) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _rTotal = _rTotal.sub(rAmount);
    _tFeeTotal = _tFeeTotal.add(tAmount);
}

Yukarıda sağlanan kodlar mekanizmanın çekirdeğini oluşturur. Farklı token'lar, uygulamalarını özelleştirmek için belirli ek işlevler içerebilir. Örneğin, bazıları büyük yatırımcılar token'larını satmaya karar verdiklerinde izdihamı önlemek için bir "takas ve likidite sağlama" işlevini güçlendirmek amacıyla işlem ücretlerini kullanabilir.

0x2 Hasar Gören Yansıma Token'larının Sonradan İncelenmesi

Daha önce belirtildiği gibi, temel odak noktamız yansıma token mekanizmasını istismar eden saldırılardır. Bu nedenle, SafeMoon V2 saldırısı (yaygın bir ERC20 herkese açık yakma sorunu) ve son ZongZi saldırısı (spot fiyatı istismar eden eski tarz fiyat manipülasyonuyla ilgili) gibi bu mekanizmayla ilgisi olmayan olaylar kapsam dışındadır.

Bu saldırıların temel nedenlerini gizemden arındırmak için derinlemesine bir analiz yaptık. Tüm bu olayların listesi için buraya başvurabilirsiniz. Bunların çoğunun kod açıklarından veya uygunsuz idari işlemlerden kaynaklanan normal güvenlik olayları olduğunu bulduk. Ancak bazıları oldukça şüpheli (örneğin, arka kapı varlığı), bunlara anormal güvenlik olayları diyoruz. Aşağıdaki alt bölümlerde, önce normal güvenlik olaylarını tanıtacak, 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 kaynaklandığını göstermektedir: kod düzeyinde sorunlar ve işlem düzeyinde sorunlar, tek başına veya birlikte.

  1. Kod düzeyinde sorunlar. Bu, geliştiricilerin yansıma token mekanizmasını tam olarak anlamamaları nedeniyle sözleşmenin berbat uygulamasından kaynaklanır ve token'ların gerçek arzı ile totalSupply'ın kayıtlı değeri arasında bir tutarsızlığa yol açar, bu da 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 kesinti

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

  2. İşlem düzeyinde sorunlar. Bu, yöneticilerin uygunsuz işleminden kaynaklanır. Özellikle, bu olaylarda, bu, düzgün bir şekilde hariç tutulmayan AMM çift adreslerinin uygunsuz yapılandırmasını ifade eder.

TÜM listelenen kod düzeyindeki sorunların, sözleşmeleri savunmasız kılarak gerçek arz ile totalSupply arasındaki tutarsızlıklara yol açabileceğini belirtmek gerekir. Ancak bu, bu açıkların istismar edilebilir olduğu veya daha kesin olarak, 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 bu açıkların istismar edilebilir olmasını sağlamak için işlem düzeyinde bir sorun gereklidir.

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

Tür Olay(lar) Temel 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 & 2) 3 (%2,38)
II (b) SHEEP ve 120'den fazlası Her ikisinin kombinasyonu (1.3 & 2) 122 (%96,83)

Tablo, Tip-II olaylarının önemli bir oranı oluşturduğunu ortaya koymaktadır. Ö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 olaylarda, istismarlar saldırganların (örneğin, bu kişi) benzer savunmasız sözleşmeleri belirlemek için otomatik yöntemler kullanıyor olabileceğini göstermektedir. 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 aittir, yani toplam arzı etkileyen sıfır maliyetli bir yakma olan CATOSHI olayı.

Önce CATOSHI sözleşmesindeki burnOf işlevine bakalı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 görüldüğü üzere, bu işlev tarafından yakılan miktar çağırandan (yani msg.sender) düşülmemiştir. Ancak _rOwned[msg.sender] rAmount kadar azaltılmalı ve hesap hariç tutulmuşsa, _tOwned[msg.sender] de tAmount kadar azaltılmalıdır.

Bu gözetim nedeniyle, saldırganlar başlangıçta sıfır maliyetle büyük miktarda token yakabilir ve ardından sözleşmenin reflect işlevini çağırabilir. Hem _tTotal hem de _rTotal orantılı olarak önemli ölçüde azaldığından:

reflect işlevi çağrılarak oran kolayca aşağı manipüle edilebilir ve balanceOf(saldırgan) önemli ölçüde artar. Bu, saldırganların şişirilmiş bakiyeden kâr etmesine olanak tanır.

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

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

Şöyle ki

Dolayısıyla

Bu, saldırganın bu durumda kâr elde etmek için değerli token'larla (bu durumda WETH) takas edilebilecek daha fazla token elde ettiğ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 kesinti.
  • Sorun 2: AMM çifti hariç tutulmamış.

Tip-II-a'da, sorun 1.2'deki açık formlarına göre iki alt kategoriye ayrılabilen üç saldırı olayı vardır:

1. _reflectFee işlevindeki fazladan yansıma

Bu alt kategoriye iki olay aittir: BEVO olayı ve FETA olayı. Aşağıda, BEVO sözleşmesini örnek olarak kullanacağız.

'Token Transferi için İşlevler' 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, orijinaline ek olarak iki parçaya daha bölünür: yakma ve hayır kurumu.

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'dan düşülür
    _tFeeTotal = _tFeeTotal.add(tFee);
    _tBurnTotal = _tBurnTotal.add(tBurn);
    _tCharityTotal = _tCharityTotal.add(tCharity);
    _tTotal = _tTotal.sub(tBurn);
}

Hayır kurumu hesabının hariç tutulduğuna dikkat edin, bu da bu hesaba gönderilen miktarın yakıldığı anlamına gelir, _sendToCharity işlevinde gösterildiği gibi.

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); // hayır kurumu hesabı hariç tutulduğundan, hayır kurumu kısmı yakılır
    emit Transfer(sender, currentCharity, tCharity);
}

Yukarıdaki kod parçacığından, hayır kurumu kısmını yansıtmak ve yakmak için iki yer olduğunu görebiliriz; bu durum, 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'ın değeri, ek azalma nedeniyle havuzdaki token arzından daha az olacaktır.

Tamamen teorik açıklama biraz soyut olabilir, bu nedenle süreci açıklamak için bir örnek kullanalım. Alice'in Bob'a 10 token transfer etmek istediğini ve 3 token'ın şu şekilde kesildiğini varsayalım: 1 ücret, 1 yakma ve 1 hayır kurumu için. Hayır kurumu kısmı hem yansıtıldığından hem de yakıldığından, gerçek dağılım 2 token yansıtılmış (1 ücret + 1 hayır kurumu) ve 2 token yakılmıştır (1 yakma + 1 hayır kurumu). Bob'a transfer edilecek kalan 7 token ile birlikte, bu süreçte toplam 11 token dahil olur, bu da hatalıdır.

Peki bu tutarsızlık neden kâr elde etmek için istismar edilebilir? Aşağıda, matematiksel büyü değneğimizi sallayarak sonuçları türeteceğiz.

Havuzdan (yani PancakeSwap çifti) daha önce bazı token'lar edindiğimizi varsayalım; r-uzayında rAmount ve t-uzayında tAmount olarak gösterilsin. Havuz hariç tutulmadığından, _rOwned[pair]'i rReserve olarak gösterelim ve t-uzayındaki karşılık gelen değeri de tReserve olarak gösterelim. O zaman şunu elde ederiz:

Ek azalma nedeniyle, rSupply artık havuzdaki token arzından daha azdır:

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

Bu noktada, reflect işlevi aracılığıyla tuttuğumuz token'ları yansıtırsak (bu sözleşmede deliver işlevi olarak yeniden adlandırılmıştır), oran oran' olur:

Şöyle ki

O zaman şunu elde ederiz

1, 3 ve 6 numaralı formülleri birleştirerek aşağıdaki eşitsizliği türetebiliriz:

Bu, havuzdan doğrudan (skim işlevi aracılığıyla) hasat edebileceğimiz token sayısının teslim ettiğimizden daha fazla olduğu anlamına gelir ve bu durum kârlıdır çünkü reflect işlevini çağırmanın maliyeti karşılanabilir. Bunun ardından saldırgan, hasat edilen token'ları değerli token'larla (bu durumda WBNB) takas ederek kâr elde edebilir.

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

2. _getRValues işlevinde yanlış rTransferAmount hesaplaması

Yalnızca bir olay bu forma aittir: ADU olayı. Önce aşağıdaki kod parçacığına bakalı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, tAmount'tan düşülür
    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); // Ancak rAmount'tan düşülen rTeam yok
    return (rAmount, rTransferAmount, rFee);
}

Transferler sırasında hem vergi ücretinin hem de ekip ücretinin düşülmesi gerektiğini görebiliriz. Ancak _getTvalues işlevinde tTransferAmount, hem tFee hem de tTeam kadar azaltılırken, _getRValues işlevinde yalnızca rFee düşülür. Bu tutarsızlık, daha fazla token transferi gerçekleştikçe daha da kötüleşen önceden bahsedilen tutarsızlık sorununa yol açar.

Çift, token'da da hariç tutulmadığından, bu token istismar edilebilir niteliktedir. Özellikle, bir saldırgan deliver işlevini çağırdıktan sonra çiftin skim işlevi aracılığıyla daha fazla ADU token'ı elde etmek için benzer BEVO istismarlarını kullanabilir.

Ancak o zamanki zincir üstü durumu göz önünde bulundurulduğunda, saldırganın hasat edilen ADU token'larını kâr elde etmek için WBNB ile takas etmesi imkânsızdır (tokenFromReflection işlevindeki require ifadesi nedeniyle). Bu nedenle, saldırganın kâr elde etmek için daha karmaşık bir istismar stratejisi kullanması gerekirdi ve bu burada ayrıntılandırılmayacaktır.

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Miktar toplam yansımalardan az olmalıdır");
    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-uzayı ve t-uzayı değerleri arasındaki karışıklık (kâr elde etmek için hassasiyet kaybının da mevcut olması gerektiğini not edin).
  • Sorun 2: AMM çifti hariç tutulmamış.

Sorun 1.3, dahili _burn işlevinin uygulanması sırasında r-uzayı ve t-uzayı 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, bir r-uzayı değeri kadar azaltılmalıdır
    _tTotal = _tTotal.sub(_value); // _tTotal, bir t-uzayı değeri kadar azaltılmalıdır
    // Yakma işlevinin semantiği için _rTotal da bir r-uzayı değeri kadar çıkarılmalıdır.
    emit Transfer(_who, address(0), _value);
}

Sözleşmenin temel sabitlerini göz önünde bulundurarak, r-uzayı değeri genellikle t-uzayı değerinin büyük bir katıdır. Bu nedenle, tSupply ile aynı büyüklük mertebesinde bir _value ile burn işlevini çağırmak oranı önemli ölçüde şişirecektir.

Ancak Tip-I durumlarının aksine, çağıran, kendi bakiyesini değiştirmeden token'ları yakamaz. Başka bir deyişle, saldırganın daha fazla token elde etmesi zor, hatta imkânsızdır. Peki, Tip-II-b durumları nasıl istismar edilebilir?

SHEEP token olayını örnek alarak. Saldırganın elinde tuttuğu SHEEP token'larının değeri şu şekilde gösterilebilir:

SHEEP'in fiyatı, PancakeSwap çiftindeki spot fiyatla ifade edilebilir ve şu şekilde hesaplanır:

Ardından Değer şu şekilde ifade edilebilir:

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

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

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

İşte büyü burada: 8 ve 9'u daha da basitleştirirsek, ikinci oran açıkça birinciden daha küçüktür; bu bizi şaşırtır çünkü bu durumda kâr negatif bir değer olacaktır:

Aslında saldırgan, yansıma token'ındaki bir hassasiyet kaybı sorununu istismar etti. Hariç tutulmayan kullanıcılar için, sağladığımız formüle göre, bakiye hesaplaması aslında tokenFromReflection işlevinde 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 büyük olabilir.

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Miktar toplam yansımalardan az olmalıdır");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

Saldırı işleminde hata ayıklama yaparak, bu manipülasyonlardan önce ve sonra saldırganın ve çiftin teorik bakiyelerini 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 senkronizasyon işlevindeki izlemeyi analiz ederek, saldırganın ve çiftin teorik bakiyelerinin sırasıyla 27,523 ve 2,972 olduğunu ve bunun 9,26'lık bir oranla sonuçlandığını hesaplayabiliriz. Ancak hassasiyet kaybı nedeniyle bakiyeler sırasıyla 27 ve 2'ye aşağı yuvarlanarak oran 13,50'ye çıkar. Sonuç olarak, Kâr pozitif bir değer haline gelir.

Son olarak, saldırgan ters takas yaparak kâr elde edebilir.

0x2.2 Anormal güvenlik olayları

Bu alt bölümde, FDP ve DBALL token'larının soruşturmasından elde ettiğimiz bulguları paylaşacağız. Analizimiz, hem FDP hem de DBALL token yöneticilerinin, projeleri riske atan ve sonunda saldırılara yol açan arka kapılar olarak etkin biçimde işlev gören sorunlu ayrıcalıklı işlevleri çağırdığını göstermektedir. Özellikle DBALL projesinde, token sahibinin bir dizi şüpheli işlemini belirledik; bunlar onu bir halı çekme olarak değerlendirmek için açık kanıtlar sunmaktadır.

Bu iki token'ı hedef alan 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 çok benzemektedir. Ancak gerçek token arzının totalSupply'dan neden sapabileceğini analiz ederken, bazı şüpheli faaliyetler gün yüzüne çı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 işlev olan transferOwnership işlevinin çağrılmasından kaynaklanmaktadır. Adından da anlaşılacağı üzere, bu işlevin sözleşmenin sahipliğini değiştirmesi beklenmektedir. Ancak FDP sözleşmesinde, bu işlevin sahiplik transferiyle hiçbir ilgisi yoktur. Bunun yerine, totalSupply'ı değiştirmeden _rOwned[newOwner]'ı artırır. Bu, normal token basım sürecinin tasarım ilkelerini açıkça 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 işlevi çağıran işlemler aşağıdaki tabloda özetlenmiştir:

0x2.2.2 DBALL olayı

DBALL durumu daha karmaşık hale geliyor. MetaSleuth kullanarak DBALL sahibinin fon akışını analiz ederken, bu adrese DBALL token'larının giriş ve çıkışında bir dengesizlik gözlemledik; bu işlem için fon kaynağı kaydedilmemiş.

Zincir üzerindeki geçmiş durumları sorgulayarak, nihayetinde DBALL sahibinin bakiyesinin bu işlemden önce ve sonra değiştiğini belirledik. Sahibin t-uzayında 1 token yakmak için ayrıcalıklı manualDevBurn işlevini çağırdığı görülmektedir. Bu işlevin uygulaması aşağıdaki gibidir:

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 yolunda görünüyor. Ancak sözleşmenin 0.8'den düşük bir derleyici sürümü belirtmesi nedeniyle, _rOwned[_msgSender()]'ın çıkarılması sırasında bir aritmetik taşma meydana gelir ve bu durum 0'dan neredeyse type(uint256).max'a geçişe neden olur. Bu ince manipülasyon, sahibin bakiyesini değiştirmesine olanak tanır; ancak aynı zamanda token arzında tutarsızlığa da yol açar.

Bu sadece kazara yapılmış bir hata mı? Soruşturmamız bunun kasıtlı bir halı çekme olduğuna daha çok işaret etmektedir. Nedenler şu şekilde özetlenmiştir:

  1. Sahip, manualDevBurn işlevine yalnızca 1 token aktardı; 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. O ilişkili adres, PancakeSwap çiftinde hemen takas yaptı ve yaklaşık 56 WBNB elde etti.

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

0x3 Oranın Hesaplanmasında Potansiyel Bir Sorun

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

_getCurrentSupply işlevine bakalım. Bu işlevde, son if ifadesi rSupply'ın 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ünde, bu ifade doğruysa, hariç tutulmayan tüm kullanıcıların bakiyeleri sıfır olurdu. Ancak proje başlatıldıktan ve işlemler başladıktan sonra, if ifadesinin orijinal amacı kayboldu.

rSupply, yansıma token mekanizması nedeniyle azalacağından, oran buna bağlı olarak düşecektir. Belirli bir işlemden sonra rSupply başlangıç oranının altına düşerse, mevcut oran zıplayacak ve hariç tutulmayan tüm kullanıcılar için bakiye kayıplarına neden olacaktır. Ayrıca, teorik olarak şunun mümkün olduğu düşünülebilir:

Bu durum, hassasiyet kaybı nedeniyle oranın sıfıra düşmesine yol açarak potansiyel olarak sıfıra bölme paniğini tetikleyebilir.

0x4 Azaltma ve Çözümler

Yansıma token mekanizması, yatırımcıları ek ödüller almak için token'larını takas etmek yerine ellerinde tutmaya teşvik ederek piyasa istikrarını artırmanın bir yolunu sunar. Ancak aynı zamanda r-uzayı ve t-uzayı değerleri arasındaki karışıklık gibi yeni güvenlik zorlukları ve potansiyel riskler de beraberinde getirir. Bu nedenle, blok zinciri geliştiricileri ve yatırımcıların mekanizmayı ve potansiyel risklerini daha iyi anlamaları ve çözüm aramaları çok önemlidir.

BlockSec, hem lansmanı öncesi hem de sonrası aşamalar için güvenlik hizmetleri ve ürünleri sunmaktadır. Güvenlik denetim hizmetlerimiz, kod güvenliğini ve şeffaflığı sağlamak için kapsamlı incelemeler yapar. Phalcon ürünümüz, sürekli güvenlik izleme ve saldırı algılama özellikleri sunarak operatörlerin ve yatırımcıların projeleri izlemesine ve güvenlik riskleri tespit edildiğinde otomatik işlem yapmasına olanak tanır.

İlgili Okumalar


BlockSec Hakkında

BlockSec, tam kapsamlı bir Web3 güvenlik hizmeti sağlayıcısıdır. Şirket, kitlesel benimsenmesini kolaylaştırmak amacıyla gelişmekte olan Web3 dünyası için güvenliği 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 engelleme için Phalcon platformu, fon takibi ve soruşturma için MetaSleuth platformu ve kripto dünyasında web3 geliştiricilerinin verimli şekilde gezinmesi için MetaSuites eklentisi sunmaktadır.

Şirket bugüne kadar Uniswap Foundation, Compound, Forta ve PancakeSwap gibi 300'den fazla müşteriye hizmet vermiş ve Matrix Partners, Vitalbridge Capital ve Fenbushi Capital dahil önde gelen yatırımcılardan iki finansman turunda on milyonlarca ABD doları almıştır.