30 Kasım 2025'te, Yearn Finance'in yETH Weighted Stable Pool'u 9 milyon doların üzerinde bir tutar için istismar edildi [1]. Kök nedenler, invariant çözücüsü _calc_supply() içindeki güvenli olmayan aritmetik ve başlatma mantığına yeniden girişe izin veren devre dışı bırakılmamış bir bootstrap yolu idi. Resmi olay sonrası inceleme (post-mortem) [2], beş öğeyi kök neden olarak listelemektedir; biz bunları iki kusur (yukarıdaki güvenlik açıkları) ve yalnızca bu kusurların varlığında istismar edilebilir hale gelen iki mimari ön koşul olarak yeniden sınıflandırıyoruz. Mevcut diğer analizler, adım adım saldırı işlem detaylarına odaklanmaktadır. Üst düzey özetler ile işlem düzeyindeki detaylar arasında bir boşluk kalmaktadır: saldırı gerçekte neden ve nasıl işledi? Bu yazı, temel değerlerin adım adım nasıl değiştiğini ve hesaplamaların nerede bozulduğunu izlemek için Foundry ve Python simülasyonlarını kullanarak bu boşluğu doldurmaktadır.
Bu analiz esas olarak aşağıdaki üç katkıyı sunmaktadır:
- Güvenlik açığına göre kayıp dökümü. İki güvenlik açığı birbirine bağımlı değildir: yalnızca güvenli olmayan aritmetik ~8,1 milyon dolarlık zarara (toplamın %90'ı) neden olurken, bootstrap yolu ek olarak ~0,9 milyon dolarlık zarara imkân sağlamıştır. Bu, hangi güvenlik açığının birincil olduğunu netleştirmektedir.
- Kök nedenlerin yeniden sınıflandırılması. Resmi raporun beş kök nedeni, daha doğru bir şekilde iki uygulama kusuru (beş öğeden üçünü birleştirerek) artı yalnızca kusurlarla birlikte istismar edilebilir hale gelen iki mimari ön koşul olarak anlaşılmalıdır.
- Teknik yanlış anlaşılmaların düzeltilmesi. "İkinci iterasyondaki bir underflow, çarpım terimini sıfırlar" iddiası geçerli değildir: simülasyonlarımız, çarpımın underflow yoluyla değil, bölmedeki yuvarlama yoluyla sıfırlandığını göstermekte olup, kâr sağlayan underflow ise tamamen farklı bir aşamada gerçekleşmektedir.
Bu yazının geri kalanı şu şekilde düzenlenmiştir. Bölüm 0x1, yETH'nin weighted stable pool'u ve invariant çözücüsü hakkında arka plan bilgisi sunar. Bölüm 0x2, iki kök nedeni ve bunların başarısızlık modlarını analiz eder. Bölüm 0x3, üç aşamalı saldırıyı ayrıntılı olarak izler. Bölüm 0x4, simülasyon kanıtlarıyla iki yaygın yanlış anlaşılmayı düzeltir. Bölüm 0x5, önerilerle sonuçlanır.
Özet (TL;DR)
Kök nedenler: İki güvenlik açığı istismar edilmiştir, ancak etkileri asimetriktir:
_calc_supply()içindeki güvenli olmayan aritmetik (birincil, ~8,1 milyon dolar). Pool durumundan yETH arzını yeniden hesaplayan fonksiyon iki aritmetik hata içermektedir:unsafe_div()içindeki aşağı yuvarlama iç çarpım terimini sıfırlayabilir veunsafe_sub()içindeki underflow bir ara değeri devasa bir pozitif tam sayıya sarabilir. Bu güvenlik açığı tek başına yETH weighted stableswap pool'unu boşaltmak için yeterliydi.- Devre dışı bırakılmamış bootstrap yolu (ikincil, ~0,9 milyon dolar).
prev_supply == 0başlatma dalı, dağıtımdan sonra hiçbir zaman kalıcı olarak kapatılmamıştır. İlk güvenlik açığı arzı sıfıra indirdikten sonra, bu yol tekrar erişilebilir hale gelerek yETH/WETH Curve pool'undan ek kâr elde edilmesini sağlamıştır.
Güvenli olmayan aritmetik güvenlik açığı içerisinde, Aşama 2'de yalnızca aşağı yuvarlama hatası (Hata Modu A) kullanılmıştır; underflow hatası (Hata Modu B) bootstrap yoluyla birbirine bağımlıdır ve birlikte Aşama 3'ü mümkün kılmışlardır.
Saldırgan üç aşamalı bir dizi gerçekleştirmiştir:
- Hazırlık: Tekrarlanan ekleme/çıkarma döngüleri yoluyla pool'un varlık dağılımını çarpıtarak sanal bakiyelerde aşırı dengesizlik yaratmak.
- Arz manipülasyonu:
_calc_supply()içindeki aşağı yuvarlamayı istismar ederek çarpım terimini sıfıra indirmek, ardından bir dizi basım/yakım işlemiyle toplam arzı sıfıra düşürmek. Pool'un tüm LST'leri daha sonra çekilmiş ve WETH'e takas edilmiş olup, bu ~8,1 milyon dolarlık zarara yol açmıştır. - Kâr çıkarma: Toz (dust) miktarında yatırımlarla bootstrap yolunu (
prev_supply == 0) tetiklemek,_calc_supply()içindeki underflow'u istismar ederek ~2,35×10⁵⁶ yETH basmak; bu yETH'ler yETH/WETH Curve pool'unu boşaltmak için kullanılmış olup ~0,9 milyon dolarlık zarara yol açmıştır.
Düzeltilen iki yaygın yanlış anlaşılma:
- "Invariant,
pow_up()vepow_down()'ın farklı yuvarlaması nedeniyle bozulur." Foundry simülasyonundapow_up()'ıpow_down()ile değiştirerek doğruladık: istismar yine de çalışmaktadır. Yuvarlama uyuşmazlığı bir kök neden değildir. - "İkinci iterasyondaki bir underflow, bir ara terimin sıfıra çökmesine neden olur." Foundry ve Python simülasyonlarımız, ikinci iterasyonda hiçbir underflow oluşmadığını göstermektedir. Gerçek değer ~1,91e19'dur (iddia edilen ~1,94e18 değil), doğru bir çıkarmanın meşru bir sonucudur. Çarpımı sıfırlayan şey, sonrasında bölmede meydana gelen aşağı yuvarlama olup, bir underflow değildir.
0x1 Arka Plan
Bu olayda iki pool varlık kaybetmiştir: yETH weighted stableswap pool (LST'ler tutan bir Yearn pool'u, ~8,1 milyon dolar kayıp) ve yETH/WETH Curve pool (bir Curve stableswap pool'u, ~0,9 milyon dolar kayıp). Temel güvenlik açığı yETH weighted stableswap pool'unda bulunmaktadır. Bu bölüm, güvenlik açığını ve istismarı anlamak için gerekli arka plan bilgisini sunar.
0x1.1 Sanal Bakiyeler ve Invariant
yETH protokolü, Ethereum Likit Staking Token'ları (LST'ler) için bir Otomatik Piyasa Yapıcı (AMM)'dır [3]. Etkilenen yETH weighted stableswap pool, birden çok LST'yi tek bir pool içinde toplar: kullanıcılar LST yatırır ve pool payı token'ı olarak yETH alır.
Her LST, zaman içinde ödül biriktiren staked ETH'yi temsil ettiğinden, temel ETH'ye göre döviz kuru değişir. Muhasebeyi birleştirmek için pool, her varlık için bir sanal bakiye tanımlar: zincir üstü bakiye × döviz kuru. Bu, tüm varlıkları beacon-chain ETH birimlerine normalize eder. Tüm sanal bakiyelerin toplamı olarak gösterilir.
Pool, her biri belirlenmiş bir ağırlığa sahip 8 varlık içerir (0–7 arasında indekslenmiş):
Pool'un durumu, ağırlıklı bir StableSwap-tarzı invariant tarafından yönetilir [4]:
burada:
- , bu pool'un toplam yETH arzına doğrudan eşit olan invariant ölçeğidir. Pool mükemmel şekilde dengelendiğinde olur.
- , olarak tanımlanan ağırlıklı çarpım terimidir; burada , i varlığının ağırlığı ve 'dir.
- , tek bir protokol parametresi olan amplifikasyon faktörüdür ( değil). , bu faktörün üssüne yükseltilmesini ifade eder; burada , varlık sayısıdır (bu pool'da 8). Bu faktör, sabit toplam (dengeye yakın) ile sabit çarpım (uçlarda) arasındaki eğri şeklini kontrol eder.
Kilit özellik şudur: 'nin kapalı formda bir çözümü yoktur. Sayısal olarak çözülmesi gerekir. Bu çözücü olan _calc_supply(), aritmetik güvenlik açığının bulunduğu yerdir.
0x1.2 Invariant Çözücü
Protokol, 'yi 256 turla sınırlandırılmış sabit noktalı bir iterasyon aracılığıyla yeniden hesaplar. Bu algoritma, kodda _calc_supply() olarak uygulanmıştır (Bölüm 0x2.1'de ayrıntılı olarak açıklanmıştır). Her tur üç adım gerçekleştirir:
Adım 1: Arz tahminini güncelle.
Adım 2: Çarpım terimini yeni arza uyacak şekilde güncelle.
Adım 3: Yakınsamayı kontrol et.
Eğer ise 'yi döndür; aksi takdirde Adım 1'den tekrar et.
Başlangıç değerleri , ve , erken iterasyonları etkiler; teorik olarak nihai yakınsama açısından alakasız olsalar da, sınırlı iterasyon ve sabit hassasiyetli aritmetik nedeniyle pratikte sonuçları etkilerler.
Uygulama, sabit hassasiyetli tam sayı işlemlerini kullanır: bölme aşağı yuvarlanır ve çıkarma underflow'a karşı korunmaz. Normal pool koşulları altında, ara değerler güvenli aralıklar içinde kalır. Aşırı pool durumlarında ise kalmaz. Bölüm 0x2.1, bu başarısızlık modlarını ayrıntılı olarak analiz eder.
0x1.3 Üç Arayüz ve Invariant Çözücü
Protokol, ağırlıklı çarpım terimini (kodda vb_prod olarak saklanır) güncelleyerek pool durumunu etkileyen üç giriş noktası sunar:
| Arayüz | Ne yapar | _calc_supply()'ı tetikler mi? |
|---|---|---|
add_liquidity() |
Varlıkları rastgele oranlarda yatırır | Evet |
update_rates() |
Harici döviz kurlarını günceller | Evet |
remove_liquidity() |
Varlıkları ağırlığa göre oransal olarak çeker | Hayır (oransal ölçekleme kullanır) |
Asimetri önemlidir: add_liquidity(), rastgele oranlarda yatırımlara izin verir (pool'u ciddi şekilde çarpıtabilir), oysa remove_liquidity() her zaman oransal olarak çekim yapar. Bu nedenle, tekrarlanan ekleme/çıkarma döngüleri pool'u giderek daha dengesiz durumlara sürükleyebilir.
Kurları Güncelleme Mekanizması
Yukarıda tartışıldığı gibi, sanal bakiyeler () LST'lerin döviz kurlarına dayanarak hesaplanır. Bu nedenle kurları güncelleme yöntemini anlamak önemlidir.
Özellikle, add_liquidity() ve update_rates() fonksiyonları dahili _update_rates() fonksiyonu aracılığıyla kurları güncelleyebilirken, remove_liquidity() fonksiyonu kur senkronizasyonu gerçekleştirmez.
add_liquidity(), varlık döviz kurlarının en güncel duruma senkronize olmasını sağlamak için kritik işlemleri yürütmeden önce_update_rates()'i çağırır.update_rates(), manuel kur güncellemelerine izin verir.
_update_rates() fonksiyonu, kontrat içinde kayıtlı döviz kurlarının harici kurlarla tutarlı olup olmadığını kontrol eder. Bir tutarsızlık tespit edilirse, sanal bakiyelerin yeniden hesaplanmasını tetikler ve ardından invariant'ı günceller; aksi takdirde güncelleme işlemi atlanır.
Her Arayüzün π'yi Nasıl Ele Aldığı
Invariant'ı nasıl etkilediklerine göre, bu üç fonksiyon iki kategoriye ayrılabilir. Özellikle, add_liquidity() ve update_rates(), sanal bakiyelerde oransal olmayan değişikliklere izin verir ve bu nedenle arz ve çarpım 'nin iteratif olarak yeniden hesaplanmasını gerektirir. Buna karşılık, remove_liquidity() likiditeyi oransal olarak çeker ve iteratif hesaplama gerektirmez.
Çarpımı sıfırdan hesaplamak için temel formül şudur:
burada arzdır, , varlığının ağırlığıdır, ise onun sanal bakiyesidir (kodda vb[i] olarak saklanır) ve n varlık sayısıdır. Bu form, 'nin çarpıma dağıtılmasıyla Bölüm 0x1.1'deki tanıma cebirsel olarak eşdeğerdir.
add_liquidity()'nin iki yolu vardır (kod Bölüm 0x2.2'de gösterilmiştir):
- Bootstrap yolu (
prev_supply == 0olduğunda):vb_prod'u denklem (4)'ü kullanarak sıfırdan hesaplar. Bu yolun dağıtımdan sonra erişilebilir kalması, Bölüm 0x2.2'de tartışılan durum yönetimi güvenlik açığıdır. - Normal yol (
prev_supply > 0olduğunda): Hesaplama süreci iki adıma ayrılır:-
a) Eski ve yeni sanal bakiyelerin oranına dayalı artımlı bir güncelleme kullanır:
burada ve , yatırım öncesi ve sonrası sanal bakiyelerdir.
-
b) Bu tahmini girdi olarak
_calc_supply()'ı çağırarak, invariant 'yi ve 'nin kesin değerini yeniden hesaplayarak kesin değeri iteratif olarak kalibre eder.
-
-
update_rates(), döviz kurları değiştiğinde tetiklenir; bu, ilgili varlıkların sanal bakiyelerinin güncellenmesine neden olur. Sonraki hesaplama akışı,add_liquidity()'nin normal yolunu izler, yani invariant iteratif olarak yeniden hesaplanır. Ayrıca, yeni hesaplanan arza dayanarak, kontrat likidite arzının güncellenmiş sanal bakiye durumuyla tutarlı kalmasını sağlamak için yETH basar veya yakar. -
remove_liquidity(), her sanal bakiyeyi oransal olarak azalttıktan sonra,vb_prod'u her zaman denklem (4)'ü kullanarak sıfırdan hesaplar.
0x2 Kök Neden Analizi
İki güvenlik açığı, farklı roller ve etkilerle istismar edilmiştir. Birincil kök neden, invariant çözücüsü _calc_supply()'da bir hesaplama kusuruydu; bu kusurun iki başarısızlık modu vardı: (A) aşağı yuvarlama, çarpım terimini sıfırlayarak invariant'ı sabit toplam modeline dejenere edebilir ve fazladan LP basımına (arz enflasyonuna) yol açabilir; ve (B) bir underflow koşulu da arzı şişirebilir. Aşama 2'de (~8,1 milyon dolar) yalnızca Hata Modu A kullanılmıştır. Hata Modu B, ikincil güvenlik açığına bağımlıydı.
İkincil kök neden, bir durum yönetimi kusuruydu: pool'un başlatma dalı erişilebilir kalmıştır. Aşama 2, arzı sıfıra sürdükten sonra, Hata Modu B bootstrap yoluyla birleşerek ek ~0,9 milyon dolarlık zarara (Aşama 3) imkân sağlamıştır.
0x2.1 _calc_supply() İçindeki Güvenli Olmayan Aritmetik (Birincil)
Şekil 2, _calc_supply() uygulamasını Bölüm 0x1.2'deki matematiksel prosedürle eşleştirerek, aşağıda analiz edilen iki aritmetik hata konumunu açıklamaktadır:
Kod değişkenleri matematiksel terimlerle şu şekilde eşleşir:
| Kod değişkeni | Matematiksel rol |
|---|---|
s |
Mevcut arz tahmini |
r |
Çarpım terimi |
sp |
Sonraki arz tahmini |
l |
Pay sabiti: |
d |
Payda sabiti: |
Kritik ifadeler şunlardır:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # Step 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # Step 2: π update (per asset)
Bu fonksiyon içinde farklı satırları hedefleyen ve farklı etkiler üreten iki aritmetik başarısızlık modu bulunmaktadır. Her ikisi de tetiklenmek için pool'un aşırı bir durumda olmasını gerektirir.
Normal koşullar altında, iterasyon doğru şekilde davranır: l - s * r mütevazı bir pozitif değerdir ve iterasyon birkaç turda yakınsar.
1. Hata Modu A: Aşağı Yuvarlama Çarpımı Sıfırlar
Adım 2'de, çarpım her varlık için şu şekilde güncellenir:
r = unsafe_div(unsafe_mul(r, sp), s) # r = r * sp / s
unsafe_div(), tam sayı bölmesi gerçekleştirdiğinden, her zaman aşağı yuvarlar. Pool ciddi şekilde dengesiz olduğunda ve sp, s'den çok daha küçük olduğunda (manipüle edilmiş büyük bir yatırım sonrasında olduğu gibi), pay r * sp, paydadan s'den küçük hale gelebilir. Tam sayı bölmesi daha sonra r = 0 sonucunu verir.
r bir kez sıfır olduğunda, sonraki tüm iterasyonlar için sıfır kalır. Çarpım terimi kalıcı olarak çökmüştür.
Yaygın bir yanlış atıf, bu başarısızlığın pow_up() ve pow_down() arasındaki yuvarlama uyuşmazlığından kaynaklandığını iddia eder. Bölüm 0x4, bunun yanlış olduğuna dair kanıt sunmaktadır.
2. Hata Modu B: Underflow Arzı Şişirir
Adım 1'de, yeni arz tahmini şu şekilde hesaplanır:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # sp = (l - s*r) / d
Çıkarma l - s*r, denklem 2'deki 'e karşılık gelir. Normal koşullar altında bu pozitiftir. Ancak, pool sıfır arzla dejenere bir duruma ulaştığında, add_liquidity()'deki başlatma dalı (Bölüm 0x2.2'de ayrıntılı olarak açıklanmıştır) çarpım terimini sıfırdan yeniden hesaplar ve göreceli büyüklükler tersine dönebilir.
Özellikle, add_liquidity() sıfır arzlı bir pool üzerinde toz miktarlarla çağrıldığında, başlatma dalı denklem (4)'ü (Bölüm 0x1.3) kullanarak yeni değerler hesaplamak için _calc_vb_prod_sum()'ı çağırır. Çok küçük yatırımlarla, vb_sum çok küçüktür (örneğin, 16), ancak sıfıra yakın bakiyelere bölmek ve yüksek üslere yükseltmek çarpımı orantısız şekilde büyük bir değere (örneğin, ~9,13e20) yükseltir. s * r, l'yi aştığında, çıkarma negatif bir matematiksel sonuç verir.
unsafe_sub(), kontrol edilmeyen uint256 aritmetiği içinde çıkarma gerçekleştirdiğinden, negatif bir sonuç devasa bir pozitif tam sayıya sarar ('ya yakın). Bu sarılmış değer, bölme ve sonraki iterasyonlar boyunca yayılarak absürt derecede büyük bir arz tahmini üretir; bu tahmini protokol daha sonra gerçek yETH token'ları olarak basar.
Yaygın bir iddia, bu tür bir underflow'un belirli bir arz manipülasyonu adımının ikinci iterasyonunda gerçekleştiğini öne sürmektedir. Bölüm 0x4, bu iddianın yanlış olduğunu göstermektedir: arzı şişiren gerçek underflow, tamamen farklı bir bağlamda (saldırının Aşama 3'ünde) gerçekleşmektedir.
3. Bu Başarısızlıklar Saldırıyı Nasıl Mümkün Kılıyor
Bu iki başarısızlık modu, farklı kâr katkılarıyla istismarın farklı aşamalarında çalışır:
-
Hata Modu A (Aşama 2, ~8,1 milyon dolar): Saldırgan ciddi şekilde dengesiz bir pool'a yatırım yaptığında, çarpım terimi sıfırlanır, bu da
_calc_supply()'ın şişirilmiş bir arz döndürmesine neden olur. Protokol, saldırgana fazladan yETH basar. Bootstrap yolunun hiçbir katılımı olmaksızın, tek başına bu başarısızlık modu, saldırganın yETH weighted stableswap pool'unu LST varlıklarından boşaltmasına imkân sağlamıştır. -
Hata Modu B (Aşama 3, ~0,9 milyon dolar): Arz sıfıra düşürüldükten sonra, bootstrap yolu toz yatırımlardan büyük bir çarpım terimi yeniden hesaplayarak çıkarmanın underflow olmasına neden olur. Protokol, astronomik büyüklükte bir miktar yETH basar; saldırgan bunu ayrı yETH/WETH Curve pool'unu boşaltmak için kullanır.
Bağımlılık tek yönlüdür: Hata Modu A bağımsız olarak istismar edilebilirdir ve kayıpların %90'ına neden olmuştur, Hata Modu B ise önce arzın Hata Modu A tarafından sıfıra düşürülmesini gerektirir.
0x2.2 Devre Dışı Bırakılmamış Bootstrap Yolu (İkincil)
add_liquidity() fonksiyonu, pool'un ilk yatırımı için bir dal içerir:
Mantık şu şekilde soyutlanabilir:
if prev_supply == 0:
# Bootstrap path — compute vb_prod and vb_sum from scratch
vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
supply = vb_sum
else:
# Normal path — use stored vb_prod, perform incremental checks
...
# Called after both branches, with prev_supply == 0 as a flag
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)
prev_supply == 0 olduğunda, fonksiyon saklanan durumu atlar ve _calc_vb_prod_sum() aracılığıyla, denklem (4)'ü (Bölüm 0x1.3) kullanarak vb_prod ve vb_sum'ı sıfırdan yeniden hesaplar. Bu bootstrap dalı, pool başlatılırken tek seferlik kullanım için tasarlanmıştı ancak ilk yatırımdan sonra hiçbir zaman kalıcı olarak kapatılmadı.
Toplam arz herhangi bir yakım ve çekim kombinasyonu yoluyla sıfıra düşürülebilirse, bu dal tekrar erişilebilir hale gelir. Bu yola yeniden giren bir saldırgan, _calc_supply()'a geçirilen başlangıç koşullarını kontrol eder ve normal pool işlemi sırasında asla oluşmayacak parametreler altında yukarıda açıklanan aritmetik başarısızlıkları potansiyel olarak tetikleyebilir.
Bu bilinen bir güvenlik açığı örüntüsüdür. Ağustos 2023'te, Balancer V2 olayı benzer şekilde dahili kurları sıfırlamak için arzı sıfıra düşürmeye bağlıydı; bu, saldırganın başlatma mantığına yapay olarak avantajlı parametrelerle yeniden girmesini sağlamıştır [6]. Dağıtılmış bir pool'un başlangıç durumuna geri döndürülüp döndürülemeyeceği ve bu gerçekleştiğinde hangi invariant'ların geçerli olduğu, protokol tasarımcılarının açıkça ele alması gereken bir sorudur.
0x3 Saldırı Analizi
İstismar, saldırı işleminin [5] koordineli bir dizisi boyunca üç aşamaya ayrılmış olarak gerçekleşir. Her aşama, öncekinin oluşturduğu durum üzerine inşa edilir.
0x3.1 Aşama 1: Pool'u Çarpıtmak (Hazırlık)
Hedef: Varlıklar arasındaki sanal bakiyelerde aşırı dengesizlik yaratmak.
Aşağıdaki şekil, bu aşamanın işlem izini göstermektedir (flash loan adımı yer kısıtları nedeniyle atlanmıştır):
Saldırgan önce Balancer ve Aave'den flash loan aracılığıyla büyük miktarda LST varlığı ödünç alır; özellikle 5.500e18 wstETH, 3.100e18 WETH, 1.800e18 rETH, 2.000e18 ETHx ve 200e18 cbETH.
Ardından, saldırgan yETH/WETH Curve pool'unda yaklaşık 800e18 WETH'yi yaklaşık 416e18 yETH ile takas eder ve daha sonra elde edilen yETH'yi pool'dan likidite çekmek için kullanır.
Temel manipülasyon, Bölüm 0x1'de (Arka Plan) açıklanan arayüz asimetrisinden yararlanır: add_liquidity(), rastgele oranlarda yatırımlara izin verirken, remove_liquidity(), varlıkları pool ağırlıklarına göre oransal olarak çeker (yukarıdaki Şekil'de kırmızı dikdörtgenle vurgulanmıştır). Yalnızca seçili varlıkları yatırıp tüm varlıkları oransal olarak çekerek tekrar tekrar ekleme → çıkarma işlemlerini döngüye sokan saldırgan, pool'u kademeli olarak ciddi şekilde dengesiz bir duruma sürükler:
| Varlık | Ağırlık | Öncesi | Sonrası | Değişim |
|---|---|---|---|---|
| 0 (sfrxETH) | %20 | 628.097.482.908.289.585.170 | 684.908.495.923.316.419.717 | +%9,04 |
| 1 (wstETH) | %20 | 376.569.216.105.249.117.091 | 684.906.088.027.654.432.883 | +%81,88 |
| 2 (ETHx) | %10 | 187.473.530.249.048.974.586 | 410.441.661.092.336.995.160 | +%118,93 |
| 3 (cbETH) | %10 | 267.387.722.745.796.900.349 | 3.532.430.695.689.175.233 | -%98,68 |
| 4 (rETH) | %10 | 201.828.029.369.446.137.136 | 410.441.659.865.060.509.563 | +%103,36 |
| 5 (apxETH) | %25 | 753.792.636.209.697.936.333 | 549.134.446.963.315.842.411 | -%27,15 |
| 6 (WOETH) | %2,5 | 49.640.000.870.620.479.267 | 655.788.758.768.556.847 | -%98,68 |
| 7 (mETH) | %2,5 | 47.667.894.211.903.277.629 | 629.735.467.970.876.930 | -%98,68 |
Varlık 3 (cbETH), 6 (WOETH) ve 7 (mETH) %98'in üzerinde tükenmiştir. Bu dengesizlik doğrudan kâr çıkarmaz. Bir sonraki aşama için sayısal ön koşulları oluşturur.
0x3.2 Aşama 2: Arzı Sıfıra Çökertmek (~8,1 Milyon Dolar)
Hedef: Invariant çarpımını sıfıra düşürmek, ardından yETH arzını sıfıra indirmek. Bu aşama yalnızca birincil güvenlik açığını (güvenli olmayan aritmetik) istismar eder ve toplam kayıpların ~%90'ına neden olmuştur.
Bu aşama, üç kez uygulanan tekrarlanan beş adımlık bir döngü kullanır:
add_liquidity()aracılığıyla çarpımı bozmak;add_liquidity()aracılığıyla düzeltme için ön koşulu oluşturmak;- 0 yETH ile
remove_liquidity()aracılığıyla çarpımı sıfırlamak; update_rates()aracılığıyla arzı düzeltmek;remove_liquidity()aracılığıyla varlıkları çekmek.
Aşağıdaki şekil, beş adımlık döngünün üç tekrarının açıkça görülebildiği işlem izini göstermektedir:
1. add_liquidity() aracılığıyla çarpımı bozmak
Saldırgan, mevcut sanal bakiyesinin kabaca üç katı olan yüksek ağırlıklı varlıkların (indeksler 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH) büyük miktarlarını yatırır.
add_liquidity(), yeni çarpım terimini denklem (5)'teki (Bölüm 0x1.3) artımlı güncelleme aracılığıyla tahmin eder. Yüksek ağırlıklı varlıklar için olduğundan, oranlar tümü 1'in çok altında olan, büyük üslere yükseltilmiş kesirlerdir. Bu, 'yi ~42e18'den ~0,00353e18'e, neredeyse sıfır bir tahmini çarpıma düşürür.
Bu ufak çarpım _calc_supply()'a girer. İterasyonda, çarpım güncellemesi r = r * sp / s, Bölüm 0x2'de (Kök Neden Analizi) açıklanan aşağı yuvarlama koşuluyla karşılaşır: pay paydanın altına düşer ve tam sayı bölmesi r'yi sıfıra yuvarlar. Fonksiyon sıfır bir çarpım ve şişirilmiş bir arz (~vb_sum) döndürür ve protokolün yETH'yi fazla basmasına neden olur.
2. add_liquidity() aracılığıyla düzeltme için ön koşulu oluşturmak
Saldırgan, varlık indeksi 3 (cbETH, tükenmiş, düşük ağırlıklı bir varlık) için tek taraflı likidite ekler; varlığın mevcut pool bakiyesinin ~6,5 katını yatırır. Bu, yalnızca birkaç yETH token'ı alır, ancak pool'u sonraki iterasyonun vahşice salınmayacağı kadar yeniden dengeler.
Bu adım olmadan, Adım 3'te çarpım sıfır olmayan bir değere sıfırlansa bile, Adım 4'teki iterasyon, aşırı dengesizlikten kaynaklanan şiddetli salınımlar nedeniyle yine de sıfır bir çarpım üretecekti. Foundry simülasyonumuz bunu doğrulamaktadır: Adım 2'yi atlamak, Adım 4'teki düzeltmenin başarısız olmasına neden olur.
3. 0 yETH ile remove_liquidity() aracılığıyla çarpımı sıfırlamak
Saldırgan, remove_liquidity()'yi 0 miktarıyla çağırır. Hiçbir token çekilmez, ancak fonksiyon, denklem (4)'ü (Bölüm 0x1.3) kullanarak mevcut pool durumundan vb_prod'u yeniden hesaplar. Sanal bakiyeler sıfır olmadığından, bu sıfır olmayan bir çarpım üretir (~9,09e19), bozulan sıfır değerini geçersiz kılar.
4. update_rates() aracılığıyla arzı düzeltmek
Saldırgan, varlık indeksi 6 (WOETH) veya 7 (mETH) için update_rates()'i çağırır. Son güncellemeden bu yana döviz kuru değiştiyse, fonksiyon geri yüklenen (sıfır olmayan) çarpımla _calc_supply()'ı tetikler. Bu sefer, iterasyon doğru şekilde yakınsar ve mevcut şişirilmiş değerden çok daha düşük bir arz değeri üretir. Fark, yETH staking kontratından yakılır. Resmi olay sonrası incelemeye (post-mortem) [2] göre, bu Protokol Sahibi Likidite'yi (POL) oluşturur, yani yakımlar saldırganın değil protokolün pozisyonunu azaltır. Bu asimetri kritiktir: her döngü, saldırganın yETH bakiyesi bozulmadan kalırken toplam arzı azaltır.
Kur tutarsızlığının kendisi bir kâr kaynağı değildir; yalnızca bir tetikleme mekanizması olarak hizmet eder. Üç pool arayüzü arasında, yalnızca add_liquidity() ve update_rates(), _calc_supply()'ı çağırır; remove_liquidity() oransal ölçekleme kullanır ve çağırmaz. Adım 3, sıfır olmayan bir çarpımı geri yükledikten sonra, saldırganın ek varlık yatırmadan _calc_supply()'ı tetiklemesi gerekir. Eskimiş bir kurla update_rates()'i çağırmak tam olarak bunu başarır: kur değişikliği, saldırgan için sıfır maliyetle arz yeniden hesaplamasını tetikler.
Bu, saldırının ince bir yönünü açıklamaktadır: hazırlık aşaması (Aşama 1) sırasında, saldırgan bilinçli olarak WOETH ve mETH için likidite eklemekten kaçınmıştır. Bu kurlar add_liquidity() sırasında güncellenmiş olsaydı, kur tutarsızlığı olmazdı ve bu adımdaki update_rates(), _calc_supply()'ı tetiklemezdi.
5. remove_liquidity() aracılığıyla varlıkları çekmek
Her döngünün sonunda, saldırgan remove_liquidity() aracılığıyla varlıkları çeker.
Kâr Nasıl Çıkarılıyor
Kâr mekanizması şu şekilde çalışır: Adım 1'de, saldırgan LST'leri yatırır ve (bozulmuş çarpım nedeniyle) fazla basılmış yETH alır. Adım 4'te, arz düzeltildiğinde, fazla yETH saldırgandan değil POL'dan (staking kontratından) yakılır. Adım 5'te, saldırgan yETH bakiyesiyle orantılı olarak LST'leri çeker. POL, yakımı emerken saldırganın yETH bakiyesi bozulmadan kaldığından, saldırgan sonunda yatırdığından daha fazla LST çekmiş olur. Üç döngü boyunca çıkarılan bu fark, toplamda ~8,1 milyon dolara ulaşır.
Rebase'in Amacı
İz (birinci ve ikinci döngü arasında) ayrıca OETHVaultProxy.rebase()'e yapılan bir çağrıyı da göstermektedir; bu bir OETH rebase'ini tetikler: WOETH kontratının tuttuğu OETH bakiyesi artar, WOETH'in etkin döviz kurunu yükseltir. Bu "biriktirilen" kur tutarsızlığı, ikinci döngünün Adım 4'ünün tekrar mümkün olmasını sağlayan şeydir: update_rates() sonunda çağrıldığında, tutarsızlığı tespit eder ve _calc_supply()'ı tetikler.
Sıfıra Boşaltma
Bu beş adımlık döngüyü üç kez tekrarladıktan sonra, saldırgan pool'un toplam arzını elinde tuttuğu yETH miktarının altına indirmiştir. Kalan arzla yapılan son bir remove_liquidity() çağrısı, arzı SIFIRA boşaltır.
Pool artık sıfır arz, sıfır çarpım ve sıfır vb_sum tutmaktadır. Bu dejenere durum, önceden yatırım yapılmış bir pool'un asla başlatılmamış durumuna geri dönmeyeceği örtük tasarım varsayımını ihlal etmektedir.
0x3.3 Aşama 3: Ek Kâr İçin Sıfır Arzı İstismar Etmek (~0,9 Milyon Dolar)
Hedef: Dejenere pool durumundan devasa miktarda yETH basmak, ardından bunu gerçek varlıklarla takas etmek. Bu aşama, ikincil güvenlik açığının (devre dışı bırakılmamış bootstrap yolu) ve Hata Modu B'nin (underflow) birbirine bağımlı kombinasyonunu istismar eder ve birlikte toplam kayıpların ~%10'una katkıda bulunur.
1. Underflow Yoluyla Basım
Toplam arz sıfırken, saldırgan add_liquidity()'yi toz miktarlarla (bakiye [1, 1, 1, 1, 1, 1, 1, 9]) çağırır.
prev_supply == 0 olduğundan, kod Bölüm 0x2'de (Kök Neden Analizi) açıklanan bootstrap yoluna girer: saklanan durumu atlar ve _calc_vb_prod_sum() aracılığıyla vb_prod ve vb_sum'ı sıfırdan yeniden hesaplar, ardından bunları _calc_supply()'a geçirir. Bu, ikinci güvenlik açığının eylem halindeki halidir: saldırgan pool'u başlatılmamış durumuna geri sürmüş ve çözücüye beslenen başlangıç koşulları üzerinde kontrol elde etmiştir.
Tüm sanal bakiyeler toz seviyesinde olduğunda (döviz kurları 1e18'e yakın), hesaplanan değerler şöyledir:
vb_sum= 16vb_prod≈ 9,13e20_supply=vb_sum= 16
_calc_supply() içinde, değişkenler şu şekilde başlatılır:
l=_amplification * _vb_sum≈ 4,5e20 × 16 ≈ 7,2e21d=_amplification - PRECISION≈ 4,49e20s=_supply= 16r=_vb_prod≈ 9,13e20
Şimdi çıkarma l - s * r:
Bu negatiftir. Kontrol edilmeyen uint256 aritmetiğinde, unsafe_sub bunu yaklaşık 'ye sarar; bu astronomik derecede büyük bir değerdir. d'ye (~4,49e20) bölündükten sonra, ortaya çıkan arz tahmini ~2,35e56'dır ve protokol bu tüm miktarı saldırgana basar. Bu underflow yalnızca toplam arz Aşama 2'de sıfıra düşürüldüğü için mümkündür; dejenere olmayan herhangi bir pool durumunda, l > s * r geçerlidir ve çıkarma güvenlidir.
2. Gerçek Varlıklarla Takas
Saldırgan, fazladan basılan yETH'nin bir kısmını yETH–WETH Curve pool'unda ~1.097e18 WETH ile takas ederek WETH rezervlerini boşaltır. Aşama 1'de harcanan 800e18 WETH hesaba katıldığında, net kâr ~0,9 milyon dolardı.
Aşama 2 sırasında çıkarılan ~8,1 milyon dolarlık LST varlıklarıyla birleştiğinde, saldırgan flash loan'ları geri ödedikten sonra yaklaşık 9 milyon dolar toplam kâr elde eder.
Fon kaynağı ve hedef adresler dahil olmak üzere ayrıntılı fon akışı analizi, diğer yayınlanmış analizlerde (örn., [2]) ele alınmıştır ve bu makalenin kapsamı dışındadır.
0x4 Yanlış Anlaşılmaların Düzeltilmesi
Bu olayla ilgili yayınlanmış analizlerin çoğu, saldırganın ön koşulları nasıl oluşturduğunu tam olarak açıklamadan aritmetik semptomlara odaklanmaktadır. İki belirli iddia düzeltmeyi hak etmektedir.
0x4.1 İddia: "pow_up() ve pow_down() arasındaki yuvarlama uyuşmazlığı invariant'ı bozuyor"
Yaygın bir yorum, kök nedeni bazı kod yollarında pow_up() ve diğerlerinde pow_down() kullanımına atfederek, yönsel uyuşmazlığın istismar edilebilir tutarsızlıklar getirdiğini öne sürmektedir.
Bunu doğrudan test ettik: kontratı tüm pow_up() çağrılarını değiştirerek pow_down()'ı tek biçimli olarak kullanacak şekilde değiştirdik ve tam saldırı simülasyonunu Foundry'de yeniden çalıştırdık. İstismar tıpatıp aynı şekilde başarılı oldu. Çarpım hâlâ sıfıra çöküyor, arz hâlâ boşalıyor ve underflow hâlâ şişirilmiş bir basıma yol açıyor.
Sıfır çarpım durumunu mümkün kılan yuvarlama, iterasyon döngüsü içindeki r = unsafe_div(unsafe_mul(r, sp), s) içindeki aşağı bölmedir, başlangıç çarpım değerlerini tahmin etmek için kullanılan güç fonksiyonlarındaki yuvarlama yönü değildir.
0x4.2 İddia: "İkinci iterasyondaki underflow ara terimi sıfırlıyor"
Yaygın olarak alıntılanan bir açıklama, _calc_supply()'ın ikinci iterasyonu sırasında, unsafe_sub'daki bir underflow'un sp ≈ 1,94e18 ürettiğini ve bunun daha sonra r'nin sıfıra aşağı yuvarlanmasına neden olduğunu iddia eder.
Tam ara değerleri hem Foundry (zincir üstü tekrar oynatma) hem de Python (matematiksel doğrulama) kullanarak yeniden ürettik. Foundry simülasyonu, _calc_supply()'ı iterasyon iterasyon izler:
======= _calc_supply iteration 0 =======
l = 4905875511098192451202650000000000000000
s = 2514373972590845290489 ← initial supply
r = 3538247433646816 ← initial product (very small)
d = 4490000000000000000000
sp = (l - s*r) / d ≈ 1.093e22 ← new supply jumps ~4x
new r ≈ 4.49e22 ← product inflates dramatically
======= _calc_supply iteration 1 =======
s = 10926206313726454855296 ← from previous sp
r = 44892226765713223838396 ← from previous inner loop
sp = 19113493328251743069 ← ≈ 1.91e19, legitimately small
new r = 0 ← rounds to zero!
Kritik gözlem şudur: iterasyon 1'de, sp ~1,91e19 olarak değerlendirilir. Bu, bir underflow eseri değil, meşru olarak küçük bir pozitif değerdir. Çıkarma l - s*r, amplifikasyon ağırlıklı toplam l ve arz-çarpım terimi s*r, bu iterasyonda büyüklük olarak birbirine yakın olduğu için küçük bir pozitif sonuç üretir.
Çarpımı sıfırlayan şey, sonrasında olan şeydir: iç döngü r = r * sp / s'yi hesaplar; burada sp (~1,91e19), s'den (~1,09e22) çok daha küçüktür. Pay r * sp, paydanın s'nin altına düşer ve tam sayı bölmesi sonucu sıfıra yuvarlar.
Bunu Python'da bağımsız olarak doğrulayarak, aynı değerleri keyfi hassasiyetli tam sayılarla hesapladık ve çıkarmanın underflow olmadığını doğruladık:
Çarpım, çıkarmada underflow yoluyla değil, bölmede yuvarlama yoluyla sıfırlanır. Arzı şişiren unsafe_sub underflow'u, tamamen farklı bir bağlamda gerçekleşir: saldırının Aşama 3'ünde, sıfır arza boşaltılmış bir pool'a toz likidite eklendiğinde.
0x5 Sonuç
yETH istismarı, asimetrik etkiye sahip iki güvenlik açığını içeriyordu. _calc_supply()'daki güvenli olmayan aritmetik, birincil kök nedendi: aşağı yuvarlama hatası (Hata Modu A), tek başına Aşama 2 aracılığıyla ~8,1 milyon dolarlık zarara bağımsız olarak imkân sağlamıştır. Devre dışı bırakılmamış bootstrap yolu, ikincil bir güvenlik açığıydı; underflow hatasıyla (Hata Modu B) birleştiğinde, Aşama 3'te ek olarak ~0,9 milyon dolara imkân sağlamıştır, ancak yalnızca Aşama 2 arzı sıfıra düşürdükten sonra. Bu kayıp dökümü, mevcut analizi Aşama 2 ve Aşama 3 kârlarını ayırmayan diğer yayınlanmış raporlardan ayırmaktadır.
Resmi olay sonrası inceleme (post-mortem) [2], beş kök neden tanımlamaktadır. Biz bunları iki kusur (resmi #1 ve #5'i birleştiren güvenli olmayan aritmetik; #4 olarak devre dışı bırakılmamış bootstrap yolu) ve iki mimari ön koşul (#2 asimetrik Π işleme; #3 POL etkin sıfır-arz durumu) olarak yeniden sınıflandırıyoruz. Ayrım şudur: kusurlar, tasarım niyetini ihlal eden uygulama hatalarıdır (çözücü sıfır çarpım üretmemeli veya underflow olmamalıdır), ön koşullar ise amaçlandığı gibi işleyen ancak kusurlarla birleştiğinde istismar edilebilir saldırı yüzeyi yaratan tasarım seçimleridir.
Öneriler
- Invariant çözücülerinde kontrollü aritmetik. Gaz verimliliği pahasına bile olsa, underflow/overflow durumunda açıkça geri dönen
safe_divvesafe_subkullanın. Çözücü en fazla 256 iterasyon çalışır ve gaz ek yükü güvenlik riskine kıyasla ihmal edilebilir düzeydedir. - Ara değerlerde sınır kontrolleri. Çarpım teriminin iterasyonlar arasında makul bir aralıkta kaldığını doğrulayın. Sıfıra düşen bir çarpım veya iterasyonlar arasında büyüklük mertebelerinde artan bir arz tahmini, dejenere bir durumu işaret eder.
- Dengesizlik sınırları. Herhangi bir varlığın sanal bakiyesi ile hedef ağırlık-orantılı bakiyesi arasındaki maksimum sapmayı zorunlu kılın. Bu, Aşama 1'in ön koşulları oluşturmasını önler.
- Invariant monotonluk kontrolleri.
_calc_supply()döndükten sonra, yeni arzın değişim yönüyle tutarlı olduğunu doğrulayın (likidite ekleme asla arzı azaltmamalı, kur güncellemeleri 10 kat değişiklikler üretmemeli, vb.). - Başlatma yollarını kalıcı olarak devre dışı bırakın. Pool'un ilk yatırımından sonra,
prev_supply == 0bootstrap dalını yeniden girilemeyecek şekilde kapatın. Bu, Aşama 3'ü tamamen önleyecektir. - Sıfır-arz durumlarını önleyin. Protokol düzeyindeki yakımların (POL veya staking kontratlarından) pool sıfır olmayan bakiyeler tutarken toplam arzı sıfıra indiremeyeceğinden emin olun. Minimum bir arz tabanı, bootstrap yeniden girişini mümkün kılan dejenere duruma geçişi engelleyecektir.
- Gerçek zamanlı anomali tespiti. Anormal durum geçişlerini (çarpım terimlerinin sıfıra düşmesi, arzın büyüklük mertebelerinde değişmesi veya kısa zaman dilimlerinde tekrarlanan ekleme/çıkarma döngüleri gibi) izleyin ve kayıplar birikmeden önce uyarıları veya devre kesicilerini tetikleyin.
Referanslar
- Yearn Finance olay duyurusu
- Yearn Security olay sonrası inceleme (post-mortem)
- yETH dokümantasyonu
- yETH beyaz kitabı: invariant türetimi
- Phalcon Explorer'da saldırı işlemi
- BlockSec: Balancer boosted pool olayının analizi (Ağustos 2023)
BlockSec Hakkında
BlockSec, uçtan uca bir blockchain güvenliği ve kripto uyum sağlayıcısıdır. Protokollerin ve platformların tüm yaşam döngüsü boyunca müşterilerin kod denetimi (akıllı sözleşmeler, blockchain ve cüzdanlar dahil) yapmasına, saldırıları gerçek zamanlı olarak durdurmasına, olayları analiz etmesine, yasadışı fonları izlemesine ve AML/CFT yükümlülüklerini yerine getirmesine yardımcı olan ürünler ve hizmetler geliştiriyoruz.
BlockSec, saygın konferanslarda birden fazla blockchain güvenliği makalesi yayınlamış, çeşitli DeFi uygulamalarının sıfırıncı gün (zero-day) saldırılarını bildirmiş, 20 milyon doların üzerinde kurtarmak için birden fazla saldırıyı engellemiş ve milyarlarca dolarlık kripto para birimini güvence altına almıştır.
-
Resmi web sitesi: https://blocksec.com/
-
Resmi Twitter hesabı: https://twitter.com/BlockSecTeam



