Back to Blog

#5 Yearn Finance Olayı: Değişmez Çözücüdeki Güvensiz Aritmetik Adını Hak Ediyor

Code Auditing
February 11, 2026
22 min read

30 Kasım 2025'te Yearn Finance'ın yETH Ağırlıklı Sabit Havuzu, 9 milyon dolardan fazla kayıpla istismar edildi [1]. Temel nedenler, değişmez çözücü _calc_supply() içindeki güvenli olmayan aritmetik ve başlatma mantığına yeniden giriş yapılmasına izin veren devre dışı bırakılmamış önyükleme yolu idi. Resmi ölüm sonrası rapor [2], beş maddeyi temel neden olarak listeliyor; 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 ayrıntılarına odaklanıyor. Üst düzey özetler ile işlem düzeyindeki ayrıntılar arasında bir boşluk kalmaya devam ediyor: saldırı neden ve nasıl işe yaradı? Bu yazı, temel değerlerin adım adım nasıl evrildiğini ve hesaplamaların nerede bozulduğunu izlemek için Foundry ve Python simülasyonlarını kullanarak bu boşluğu dolduruyor.

Bu analiz öncelikle şu üç katkıyı sunmaktadır:

  1. Güvenlik açığına göre kayıp dökümü. İki güvenlik açığı birbirine bağımlı değildir: güvenli olmayan aritmetik tek başına ~8,1 milyon dolar kayba (toplamın %90'ı) neden olurken, önyükleme yolu ek ~0,9 milyon doları mümkün kıldı. Bu durum, hangi güvenlik açığının birincil olduğunu açıklığa kavuşturuyor.
  2. Temel nedenlerin yeniden sınıflandırılması. Resmi raporun beş temel nedeni, beş maddeden üçünü pekiştiren iki uygulama kusuru ve yalnızca kusurlarla birleştiğinde istismar edilebilir hale gelen iki mimari ön koşul olarak daha iyi anlaşılmaktadır.
  3. Teknik yanlış anlamaların düzeltilmesi. "İkinci yinelemede bir taşma, çarpım terimini sıfıra indirir" iddiası geçerli değildir: simülasyonlarımız, çarpımın taşma yoluyla değil, bölmedeki yuvarlama yoluyla sıfırlandığını ve kâr sağlayan taşmanın tamamen farklı bir aşamada gerçekleştiğini gösteriyor.

Bu yazının geri kalanı şu şekilde düzenlenmiştir. Bölüm 0x1, yETH'in ağırlıklı sabit havuzu ve değişmez çözücüsüne ilişkin arka plan bilgisi sunuyor. Bölüm 0x2, iki temel nedeni ve bunların hata modlarını inceliyor. Bölüm 0x3, üç aşamalı saldırıyı ayrıntılı biçimde izliyor. Bölüm 0x4, simülasyon kanıtlarıyla iki yaygın yanlış anlamayı düzeltiyor. Bölüm 0x5 önerilerle sonuca varıyor.

TL;DR

Temel nedenler: İki güvenlik açığı istismar edildi, ancak asimetrik bir etkiyle:

  1. _calc_supply() içindeki güvenli olmayan aritmetik (birincil, ~8,1 milyon dolar). Havuz durumundan yETH arzını yeniden hesaplayan fonksiyon iki aritmetik hata içeriyor: unsafe_div() içindeki aşağı yuvarlama, dahili çarpım terimini sıfırlayabilir ve unsafe_sub() içindeki taşma, bir ara değeri devasa pozitif bir tamsayıya sarabilir. Bu güvenlik açığı tek başına yETH ağırlıklı sabit takas havuzunu boşaltmak için yeterliydi.
  2. Devre dışı bırakılmamış önyükleme yolu (ikincil, ~0,9 milyon dolar). prev_supply == 0 başlatma dalı, dağıtımdan sonra hiçbir zaman kalıcı olarak engellenmedi. Birinci güvenlik açığı arzı sıfıra indirdikten sonra bu yol erişilebilir hale geldi ve yETH/WETH Curve havuzundan ek kâr elde edilmesini sağladı.

Güvenli olmayan aritmetik güvenlik açığı kapsamında, yalnızca aşağı yuvarlama hatası (Hata Modu A) 2. Aşamada kullanıldı; taşma hatası (Hata Modu B) önyükleme yoluyla birlikte bağımlıdır ve birlikte 3. Aşamayı mümkün kıldı.

Saldırgan üç aşamalı bir sıra gerçekleştirdi:

  1. Hazırlık: Tekrarlanan ekleme/kaldırma döngüleri aracılığıyla havuzun varlık dağılımını çarpıtarak sanal bakiyelerde aşırı dengesizlik yaratma.
  2. Arz manipülasyonu: _calc_supply() içindeki aşağı yuvarlamayı kullanarak çarpım terimini çöküşe geçirme, ardından bir dizi basım/yakma işlemiyle toplam arzı sıfıra indirme. Havuzun tüm LST'leri çekildi ve ardından WETH'e takas edilerek ~8,1 milyon dolar kayba yol açtı.
  3. Kâr çıkarma: Toz miktarda mevduatlarla önyükleme yolunu (prev_supply == 0) tetikleme, yETH/WETH Curve havuzunu boşaltmak için kullanılan ~2,35×10⁵⁶ yETH basmak amacıyla _calc_supply() içindeki taşmayı istismar etme; bu da ~0,9 milyon dolar kayba yol açtı.

Düzeltilen iki yaygın yanlış anlama:

  • "pow_up() ve pow_down() farklı yuvarlama yapmasından dolayı değişmez bozuluyor." Foundry simülasyonunda pow_up() yerine pow_down() kullanarak bunu doğruladık: istismar aynı şekilde çalışıyor. Yuvarlama uyuşmazlığı bir temel neden değildir.
  • "İkinci yinelemede bir taşma, ara terimi sıfıra indirir." Foundry ve Python simülasyonlarımız, ikinci yinelemede herhangi bir taşma olmadığını gösteriyor. Gerçek değer ~1,91e19'dur (iddia edildiği gibi ~1,94e18 değil), doğru bir çıkarmanın meşru bir sonucudur. Çarpımı sıfırlayan şey, bölmedeki sonraki aşağı yuvarlamadır, taşma değil.

0x1 Arka Plan

Bu olayda iki havuz varlık kaybetti: yETH ağırlıklı sabit takas havuzu (LST'ler tutan bir Yearn havuzu, ~8,1 milyon dolar kayıp) ve yETH/WETH Curve havuzu (bir Curve sabit takas havuzu, ~0,9 milyon dolar kayıp). Temel güvenlik açığının bulunduğu yer yETH ağırlıklı sabit takas havuzudur. Bu bölüm, güvenlik açığını ve istismarı anlamak için gerekli arka plan bilgisini sunuyor.

0x1.1 Sanal Bakiyeler ve Değişmez

yETH protokolü, Ethereum Likit Staking Token'ları (LST'ler) için bir Otomatik Piyasa Yapıcısıdır (AMM) [3]. Etkilenen yETH ağırlıklı sabit takas havuzu, birden fazla LST'yi tek bir havuzda bir araya getiriyor: kullanıcılar LST yatırıyor ve havuz payı token'ı olarak yETH alıyor.

Her LST, zaman içinde ödül biriktiren stake edilmiş ETH'i temsil ettiğinden, temel ETH'ye göre döviz kuru değişiyor. Muhasebeyi birleştirmek için havuz, her varlık için bir sanal bakiye xix_i tanımlar: zincir üstü bakiye × döviz kuru. Bu, tüm varlıkları işaret zinciri ETH birimlerine normalleştiriyor. Tüm sanal bakiyelerin toplamı σ=xi\sigma = \sum x_i olarak gösterilir.

Havuz 8 varlık içeriyor (0–7 arasında indekslenmiş), her birinin belirlenmiş bir ağırlığı wiw_i var:

Dizin Varlık Dizin Varlık
0 sfrxETH 4 rETH
1 wstETH 5 apxETH
2 ETHx 6 WOETH
3 cbETH 7 mETH

Havuzun durumu, ağırlıklı StableSwap tarzı bir değişmez tarafından yönetiliyor [4]:

Afn  σ+D=Afn  D+Dπ(1)\mathit{Af}^{\,n}\;\sigma + D = \mathit{Af}^{\,n}\;D + D \cdot \pi \tag{1}

burada:

  • DD, değişmez ölçeğidir ve doğrudan bu havuzun toplam yETH arzına eşittir. Havuz mükemmel dengede olduğunda D=σD = \sigma.
  • π\pi, ağırlıklı çarpım terimidir; π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i} olarak tanımlanır; burada wiw_i i varlığının ağırlığı ve vi=winv_i = w_i \cdot n'dir.
  • Af\mathit{Af}, yükseltme faktörüdür; tek bir protokol parametresidir (A×fA \times f değil). Afn\mathit{Af}^{\,n}, bu faktörün nn üssüne yükseltilmiş değerini gösterir; burada nn varlık sayısıdır (bu havuzda 8). Eğri şeklini sabit toplam (denge yakınında) ile sabit çarpım (uç noktalarda) arasında kontrol eder.

Temel özellik: DD'nin kapalı form bir çözümü yoktur. Sayısal olarak çözülmesi gerekir. _calc_supply() adlı bu çözücü, aritmetik güvenlik açığının bulunduğu yerdir.

0x1.2 Değişmez Çözücü

Protokol, 256 turla sınırlı sabit noktalı bir yineleme aracılığıyla DD'yi yeniden hesaplar. Bu algoritma, kodda _calc_supply() olarak uygulanmaktadır (Bölüm 0x2.1'de ayrıntılı olarak ele alınmıştır). Her tur üç adım gerçekleştirir:

Adım 1: Arz tahminini güncelle.

Dm+1=AfnσDmπmAfn1(2)D_{m+1} = \frac{\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m}{\mathit{Af}^{\,n} - 1} \tag{2}

Adım 2: Çarpım terimini yeni arza göre güncelle.

πm+1=πm(Dm+1Dm)n(3)\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n \tag{3}

Adım 3: Yakınsamayı kontrol et.

Dm+1Dm<ϵ|D_{m+1} - D_{m}| < \epsilon ise DmD_{m}'yi döndür; aksi takdirde Adım 1'den tekrarla.

D0D_0, π0\pi_0 ve σ\sigma başlangıç değerleri ilk yinelemeleri etkiler; teorik olarak nihai yakınsama için ilgisiz olsalar da, sonlu yineleme ve sabit hassasiyetli aritmetik nedeniyle pratikte sonuçları etkilerler.

Uygulama, sabit hassasiyetli tamsayı işlemleri kullanır: bölme aşağı yuvarlar ve çıkarma taşmaya karşı koruma sağlamaz. Normal havuz koşullarında, ara değerler güvenli aralıklarda kalır. Aşırı havuz durumlarında kalmaz. Bölüm 0x2.1, bu hata modlarını ayrıntılı olarak analiz ediyor.

0x1.3 Üç Arayüz ve Değişmez Çözücü

Protokol, ağırlıklı çarpım terimi π\pi'yi güncelleyerek havuz durumunu etkileyen üç giriş noktası sunuyor (kodda vb_prod olarak saklanır):

Arayüz Ne yapar _calc_supply() tetikliyor mu?
add_liquidity() Varlıkları rastgele oranlarda yatırır Evet
update_rates() Harici döviz kurlarını günceller Evet
remove_liquidity() Ağırlığa göre orantılı olarak varlıkları çeker Hayır (orantılı ölçekleme kullanır)

Bu asimetri önemlidir: add_liquidity(), rastgele oranlı yatırımlara izin verirken (havuzu büyük ölçüde çarpıtabilir), remove_liquidity() her zaman orantılı şekilde çeker. Bu nedenle tekrarlanan ekleme/kaldırma döngüleri, havuzu giderek daha dengesiz durumlara taşıyabilir.

Kurları Güncelleme Mekanizması

Yukarıda tartışıldığı gibi, sanal bakiyeler (xix_i) LST'lerin döviz kurlarına göre hesaplanır. Bu nedenle kurların güncelleme şeklini anlamak önemlidir.

Özellikle, add_liquidity() ve update_rates() fonksiyonları iç _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 son durumla senkronize olmasını sağlamak için kritik işlemleri yürütmeden önce _update_rates() çağırır.
  • update_rates() manuel kur güncellemelerine izin verir.

_update_rates() fonksiyonu, sözleşmede 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 değişmezi günceller; aksi takdirde güncelleme süreci atlanır.

Her Arayüzün π'yi Nasıl İşlediği

Değişmezi nasıl etkilediklerine göre, bu üç fonksiyon iki kategoriye ayrılabilir. Özellikle, add_liquidity() ve update_rates(), sanal bakiyelerde orantısız değişikliklere izin verir ve bu nedenle arz DD ve çarpım π\pi'nin yinelemeli olarak yeniden hesaplanmasını gerektirir. Buna karşın, remove_liquidity() likiditeyi orantılı olarak çeker ve yinelemeli hesaplama gerektirmez.

Çarpımı sıfırdan hesaplamak için temel formül:

π=i(Dwixi)nwi(4)\pi = \prod_{i} \left(\frac{D \cdot w_i}{x_i}\right)^{n \cdot w_i} \tag{4}

burada DD arz, wiw_i i varlığının ağırlığı, xix_i sanal bakiyesi (kodda vb[i] olarak saklanır) ve n varlık sayısıdır. Bu form, DnD^n çarpıma dağıtılmış şekilde Bölüm 0x1.1'deki tanımla cebirsel olarak eşdeğerdir.

  1. add_liquidity() iki yola sahiptir (kod Bölüm 0x2.2'de gösterilmiştir):
  • Önyükleme yolu (prev_supply == 0 olduğunda): Denklem (4) kullanarak vb_prod'u sıfırdan hesaplar. Bu yolun dağıtımdan sonra erişilebilir kalması, Bölüm 0x2.2'de ele alınan durum yönetimi güvenlik açığıdır.
  • Normal yol (prev_supply > 0 olduğunda): Hesaplama süreci iki adıma bölünmüştür:
    • a) Eski ile yeni sanal bakiyeler arasındaki orana dayalı artımlı bir güncelleme kullanır:

      πestimated=πi=0n1(xixi)win(5)\pi_{\text{estimated}} = \pi \cdot \prod_{i=0}^{n-1} \left(\frac{x_i}{x_i'}\right)^{w_i \cdot n} \tag{5}

      burada xix_i ve xix_i' sırasıyla mevduattan önceki ve sonraki sanal bakiyelerdir.

    • b) Bu tahmini girdi olarak _calc_supply() çağırarak, değişmez DD'yi ve π\pi'nin tam değerini yeniden hesaplayarak kesin değeri yinelemeli olarak kalibre eder.

  1. update_rates(), döviz kurları değiştiğinde tetiklenir ve ilgili varlıkların sanal bakiyelerinin güncellenmesine neden olur. Sonraki hesaplama akışı, add_liquidity()'nin normal yolunu izler, yani değişmez yinelemeli olarak yeniden hesaplanır. Ayrıca, yeni hesaplanan arza göre sözleşme, likidite arzının güncellenen sanal bakiye durumuyla tutarlı kalmasını sağlamak için yETH basar veya yakar.

  2. remove_liquidity() her zaman, her sanal bakiyeyi orantılı olarak azalttıktan sonra denklem (4) kullanarak vb_prod'u sıfırdan hesaplar.


0x2 Temel Neden Analizi

Farklı roller ve etkilerle iki güvenlik açığı istismar edildi. Birincil temel neden, değişmez çözücü _calc_supply() içindeki bir hesaplama hatasıydı; bu hatanın iki hata modu vardı: (A) aşağı yuvarlama, çarpım terimini sıfırlayarak değişmezi sabit toplam modele dönüştürebilir ve aşırı LP basımına (arz enflasyonu) yol açabilir; ve (B) bir taşma koşulu da arzı şişirebilir. Yalnızca Hata Modu A, 2. Aşamada (~8,1 milyon dolar) kullanıldı. Hata Modu B, ikincil güvenlik açığına bağımlıydı.

İkincil temel neden, bir durum yönetimi kusuruyddu: havuzun başlatma dalı erişilebilir kaldı. 2. Aşama arzı sıfıra indirdikten sonra, Hata Modu B önyükleme yoluyla birleşerek ek ~0,9 milyon dolar kayba (3. Aşama) olanak tanıdı, ancak yalnızca 2. Aşama arzı zaten sıfıra indirdikten sonra.

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 noktasına açıklama ekliyor:

Kod değişkenleri matematiksel terimlere şu şekilde eşleniyor:

Kod değişkeni Matematiksel rol
s Mevcut arz tahmini DmD_m
r Çarpım terimi πm\pi_m
sp Sonraki arz tahmini Dm+1D_{m+1}
l Pay sabiti: Afnσ\mathit{Af}^{\,n} \cdot \sigma
d Payda sabiti: Afn1\mathit{Af}^{\,n} - 1

Kritik ifadeler şunlardır:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # Adım 1: D[m+1]
r  = unsafe_div(unsafe_mul(r, sp), s)                 # Adım 2: π güncellemesi (varlık başına)

Bu fonksiyon içinde farklı satırları hedefleyen ve farklı etkiler üreten iki aritmetik hata modu mevcuttur. Her ikisi de tetiklemek için havuzun aşırı bir durumda olmasını gerektirir.

Normal koşullar altında, yineleme doğru davranır: l - s * r mütevazı bir pozitif değerdir ve yineleme birkaç turda yakınsar.

1. Hata Modu A: Aşağı Yuvarlama Çarpımı Sıfırlar

Adım 2'de, çarpım varlık başına şu şekilde güncelleniyor:

r = unsafe_div(unsafe_mul(r, sp), s)   # r = r * sp / s

unsafe_div() tamsayı bölmesi yaptığından, her zaman aşağı yuvarlar. Havuz ciddi ölçüde dengesizleştiğinde ve sp, s'den çok daha küçük olduğunda (manipüle edilmiş büyük bir mevduattan sonra olduğu gibi), r * sp payı, s paydasından küçülebilir. Tamsayı bölmesi ardından r = 0 üretir.

r bir kez sıfırlandığında, sonraki tüm yinelemeler için sıfır kalır. π\pi çarpım terimi kalıcı olarak çökmüştür.

Yaygın bir yanlış atıf, bu hatanın pow_up() ve pow_down() arasındaki yuvarlama uyuşmazlığından kaynaklandığını öne sürmektedir. Bölüm 0x4, bunun doğru olmadığına dair kanıtlar sunuyor.

2. Hata Modu B: Taşma 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

l - s*r çıkarması, denklem 2'deki AfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m'dir. Normal koşullar altında bu pozitiftir. Ancak havuz, sıfır arzla bozulmuş bir duruma ulaştığında, add_liquidity() içindeki 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öreli büyüklükler tersine dönebilir.

Özellikle, add_liquidity() sıfır arzlı bir havuzda toz miktarda çağrıldığında, başlatma dalı denklem (4) kullanarak taze değerleri hesaplamak için _calc_vb_prod_sum() çağırır (Bölüm 0x1.3). Küçük mevduatlarla vb_sum son derece 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 biçimde 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(), işaretsiz uint256 aritmetiğinde çıkarma yaptığından, negatif bir sonuç devasa pozitif bir tamsayıya (yaklaşık 22562^{256}'ya yakın) sarılır. Bu sarılmış değer, bölme ve sonraki yinelemeler boyunca yayılır ve protokolün ardından gerçek yETH token'ları olarak bastığı absürt derecede büyük bir arz tahmini üretir.

Yaygın bir iddia, böyle bir taşmanın belirli bir arz manipülasyon adımının ikinci yinelemesinde gerçekleştiğini öne sürmektedir. Bölüm 0x4, bu iddianın yanlış olduğunu gösteriyor: arzı şişiren gerçek taşma, saldırının tamamen farklı bir bağlamında (3. Aşama) gerçekleşiyor.

3. Bu Hataların Saldırıyı Nasıl Mümkün Kıldığı

Bu iki hata modu, farklı kâr katkılarıyla saldırının farklı aşamalarında çalışır:

  • Hata Modu A (2. Aşama, ~8,1 milyon dolar): Saldırgan ciddi ölçüde dengesizleşmiş bir havuza mevduat yatırdığında, çarpım terimi sıfırlanır ve _calc_supply() şişirilmiş bir arz döndürür. Protokol, saldırgana fazla yETH basar. Bu hata modu tek başına, önyükleme yolunun herhangi bir müdahalesi olmadan, saldırganın yETH ağırlıklı sabit takas havuzundan LST varlıklarını boşaltmasını sağladı.

  • Hata Modu B (3. Aşama, ~0,9 milyon dolar): Arz sıfıra indirildiğinde, önyükleme yolu toz mevduatlardan büyük bir çarpım terimi yeniden hesaplar ve çıkarmanın taşmasına neden olur. Protokol, saldırganın ayrı yETH/WETH Curve havuzunu boşaltmak için kullandığı astronomik miktarda yETH basar.

Bağımlılık tek yönlüdür: Hata Modu A bağımsız olarak istismar edilebilir ve kayıpların %90'ına neden olmuştur; Hata Modu B ise önce arzı sıfıra indirmek için Hata Modu A'yı gerektirir.

0x2.2 Devre Dışı Bırakılmamış Önyükleme Yolu (İkincil)

add_liquidity() fonksiyonu, havuzun ilk mevduatı için bir dal içeriyor:

Mantık şu şekilde özetlenebilir:

if prev_supply == 0:
    # Önyükleme yolu — vb_prod ve vb_sum'u sıfırdan hesapla
    vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
    supply = vb_sum
else:
    # Normal yol — saklanan vb_prod'u kullan, artımlı kontroller gerçekleştir
    ...

# Her iki daldan sonra çağrılır, prev_supply == 0 bayrak olarak kullanılır
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) kullanarak vb_prod ve vb_sum'u sıfırdan yeniden hesaplar (Bölüm 0x1.3). Bu önyükleme dalı, havuz başlatma sırasında tek seferlik kullanım için tasarlanmıştı, ancak ilk mevduattan sonra hiçbir zaman kalıcı olarak engellenmedi.

Toplam arz herhangi bir yakma ve çekme kombinasyonu aracılığıyla sıfıra indirilebilirse, dal yeniden erişilebilir hale gelir. Bu yola yeniden giren bir saldırgan, normal havuz işlemi sırasında hiç ortaya çıkmayacak parametreler altında yukarıda açıklanan aritmetik hataları potansiyel olarak tetikleyerek _calc_supply()'ye iletilen başlangıç koşullarını kontrol eder.

Bu, bilinen bir güvenlik açığı kalıbıdır. Ağustos 2023'te Balancer V2 olayı, benzer şekilde dahili kurları sıfırlamak için arzı sıfıra indirmeye bağlıydı ve saldırganın başlatma mantığına yapay olarak elverişli parametrelerle yeniden girmesini sağladı [6]. Dağıtılmış bir havuzun ilk durumuna geri döndürülüp döndürülemeyeceği ve döndürüldüğünde hangi değişmezlerin 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çılıyor ve üç aşamada düzenleniyor. Her aşama, bir öncekinin oluşturduğu durumu temel alıyor.

0x3.1 1. Aşama: Havuzu Çarpıtma (Hazırlık)

Amaç: Varlıklar arasında sanal bakiyelerde aşırı dengesizlik yaratmak.

Aşağıdaki şekil, bu aşamaya ait işlem izini gösteriyor (flash kredi adımı alan kısıtlamaları nedeniyle atlanmıştır):

Saldırgan önce Balancer ve Aave'den flash kredi aracılığıyla büyük miktarda LST varlığı borç alıyor; spesifik olarak 5.500e18 wstETH, 3.100e18 WETH, 1.800e18 rETH, 2.000e18 ETHx ve 200e18 cbETH.

Ardından saldırgan, yETH/WETH Curve havuzunda yaklaşık 800e18 WETH'i yaklaşık 416e18 yETH için takas ediyor ve ardından edinilen yETH'i havuzdan likiditeyi kaldırmak için kullanıyor.

Temel manipülasyon, Bölüm 0x1'de (Arka Plan) açıklanan arayüz asimetrisinden yararlanıyor: add_liquidity() rastgele oranlı mevduatlara izin verirken, remove_liquidity() varlıkları havuz ağırlıklarına göre orantılı çekiyor (yukarıdaki Şekilde kırmızı dikdörtgen içinde vurgulanmıştır). Ekleme → kaldırma işlemlerini tekrar tekrar döngüye alarak, yalnızca seçili varlıklar yatırılırken tüm varlıklar orantılı olarak çekildiğinde, saldırgan havuzu giderek daha ciddi şekilde dengesiz bir duruma taşıyor:

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'den fazla tükendi. Bu dengesizlik doğrudan kâr çıkarmıyor. Bir sonraki aşama için sayısal ön koşulları oluşturuyor.

0x3.2 2. Aşama: Arzı Sıfıra İndirme (~8,1 Milyon Dolar)

Amaç: Değişmez ç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) kullanıyor ve toplam kayıpların ~%90'ına neden oldu.

Bu aşama, üç kez yürütülen tekrar eden beş adımlı bir döngü kullanıyor:

  1. add_liquidity() aracılığıyla çarpımı bozma;
  2. add_liquidity() aracılığıyla düzeltme için ön koşul oluşturma;
  3. 0 yETH ile remove_liquidity() aracılığıyla çarpımı sıfırlama;
  4. update_rates() aracılığıyla arzı düzeltme;
  5. remove_liquidity() aracılığıyla varlıkları çekme.

Aşağıdaki şekil, beş adımlı döngünün üç tekrarının açıkça görülebildiği işlem izini gösteriyor:

1. add_liquidity() Aracılığıyla Çarpımı Bozma

Saldırgan, yüksek ağırlıklı varlıklar (indeks 0, 1, 2, 4, 5: sfrxETH, wstETH, ETHx, rETH, apxETH) için büyük miktarlar yatırıyor; her biri mevcut sanal bakiyesinin yaklaşık üç katı.

add_liquidity(), denklem (5) (Bölüm 0x1.3) içindeki artımlı güncelleme aracılığıyla yeni çarpım terimini tahmin eder. Yüksek ağırlıklı varlıklar için xixix_i' \gg x_i olduğundan, (xi/xi)(x_i / x_i') oranlarının tamamı yüksek üslere yükseltilmiş 1'in oldukça altındaki kesirlerdir. Bu, πnew\pi_{\text{new}}'i ~42e18'den ~0,00353e18'e, neredeyse sıfır tahmini çarpıma düşürüyor.

Bu küçük çarpım _calc_supply()'ye giriyor. Yinelemede, r = r * sp / s çarpım güncellemesi Bölüm 0x2'de (Temel Neden Analizi) açıklanan aşağı yuvarlama koşuluyla karşılaşıyor: pay, paydanın altına düşüyor ve tamsayı bölmesi r'yi sıfıra indiriyor. Fonksiyon sıfır çarpım ve şişirilmiş arz (~vb_sum) döndürüyor ve protokolün fazla yETH basmasına neden oluyor.

2. add_liquidity() Aracılığıyla Düzeltme için Ön Koşul Oluşturma

Saldırgan, varlık indeksi 3 için (cbETH, tükenmiş düşük ağırlıklı varlık) tek taraflı likidite ekliyor; varlığın mevcut havuz bakiyesinin yaklaşık 6,5 katını yatırıyor. Bu yalnızca birkaç yETH token alıyor, ancak havuzu bir sonraki yinelemenin şiddetli salınımlar üretmeyeceği ölçüde yeniden dengeliyor.

Bu adım olmadan, 3. Adımda çarpım sıfırdan farklı değere sıfırlandıktan sonra bile, 4. Adımdaki yineleme aşırı dengesizlikten kaynaklanan şiddetli salınımlar nedeniyle hâlâ sıfır çarpım üretecekti. Foundry simülasyonumuz bunu doğruluyor: 2. Adımı atlamak, 4. Adımdaki düzeltmenin başarısız olmasına neden oluyor.

3. 0 yETH ile remove_liquidity() Aracılığıyla Çarpımı Sıfırlama

Saldırgan, remove_liquidity()'yi 0 miktarla çağırıyor. Hiçbir token çekilmiyor, ancak fonksiyon denklem (4) (Bölüm 0x1.3) kullanarak mevcut havuz durumundan vb_prod'u yeniden hesaplıyor. Sanal bakiyeler sıfırdan farklı olduğundan, bu sıfırdan farklı bir çarpım (~9,09e19) üretiyor ve bozulmuş sıfır değerinin üzerine yazıyor.

4. update_rates() Aracılığıyla Arzı Düzeltme

Saldırgan, varlık indeksi 6 (WOETH) veya 7 (mETH) için update_rates() çağırıyor. Son güncellemeden bu yana döviz kuru değişmişse, fonksiyon geri yüklenen (sıfırdan farklı) çarpımla _calc_supply()'yi tetikler. Bu sefer yineleme doğru şekilde yakınsar ve mevcut şişirilmiş değerden çok daha düşük bir arz değeri üretir. Fark, yETH staking sözleşmesinden yakılır. Resmi ölüm sonrası rapora [2] göre, bu Protokol Sahipliğindeki Likiditeyi (POL) oluşturuyor; yani yakma işlemi saldırganın pozisyonu yerine protokolün pozisyonunu azaltıyor. Bu asimetri kritik önem taşıyor: her döngü saldırganın yETH bakiyesi değişmeden kalırken toplam arzı azaltıyor.

Kur tutarsızlığının kendisi kâr kaynağı değildir; yalnızca bir tetikleme mekanizması işlevi görür. Üç havuz arayüzü arasında, yalnızca add_liquidity() ve update_rates() _calc_supply()'yi çağırır; remove_liquidity() orantılı ölçekleme kullanır ve bunu yapmaz. 3. Adım sıfırdan farklı çarpımı geri yükledikten sonra, saldırganın ek varlık yatırmadan _calc_supply()'yi tetiklemesi gerekiyor. Bayat bir kurla update_rates() çağırmak tam olarak bunu sağlıyor: kur değişikliği saldırgana sıfır maliyetle arz yeniden hesaplamasını tetikliyor.

Bu, saldırının ince bir yönünü açıklıyor: hazırlık aşamasında (1. Aşama), saldırgan kasıtlı olarak WOETH ve mETH için likidite eklemeyi kaçındı. Bu kurlar add_liquidity() sırasında güncellenseydi, hiçbir kur tutarsızlığı olmazdı ve bu adımdaki update_rates() _calc_supply()'yi tetiklemezdi.

5. remove_liquidity() Aracılığıyla Varlıkları Çekme

Her döngünün sonunda, saldırgan remove_liquidity() aracılığıyla varlıkları çekiyor.

Kârın Nasıl Çıkarıldığı

Kâr mekanizması şu şekilde çalışıyor: 1. Adımda, saldırgan LST yatırıyor ve fazla basılmış yETH alıyor (bozulmuş çarpım nedeniyle). 4. Adımda, arz düzeltildiğinde, fazla yETH POL'den (staking sözleşmesi) yakılıyor, saldırgandan değil. 5. Adımda, saldırgan yETH varlıklarıyla orantılı LST'leri çekiyor. POL yakımı absorbe ederken saldırganın yETH bakiyesi değişmeden kaldığından, saldırgan yatırdığından daha fazla LST çekiyor. Üç döngü boyunca çıkarılan bu fark, toplamda ~8,1 milyon dolar tutuyor.

Yeniden Tabanlama Amacı

İz (birinci ve ikinci döngü arasında) ayrıca bir OETHVaultProxy.rebase() çağrısı da gösteriyor; bu, OETH yeniden tabanlamasını tetikliyor: WOETH sözleşmesi tarafından tutulan OETH bakiyesi artıyor ve WOETH'in etkin döviz kurunu yükseltiyor. Bu "kaydedilmiş" kur tutarsızlığı, ikinci döngünün 4. Adımını yeniden mümkün kılıyor: update_rates() sonunda çağrıldığında tutarsızlığı algılıyor ve _calc_supply()'yi tetikliyor.

Sıfıra İndirme

Bu beş adımlı döngüyü üç kez tekrarladıktan sonra, saldırgan havuzun toplam arzını elinde tuttuğu yETH miktarının altına indirmiş oluyor. Kalan arzla son bir remove_liquidity() çağrısı havuzu SIFIRA indiriyor.

Havuz artık sıfır arz, sıfır çarpım ve sıfır vb_sum tutuyor. Bu bozulmuş durum, önceki mevduatları olan bir havuzun hiçbir zaman başlatılmamış durumuna geri dönmeyeceği yönündeki örtük tasarım varsayımını ihlal ediyor.

0x3.3 3. Aşama: Ek Kâr için Sıfır Arz Durumunu İstismar Etme (~0,9 Milyon Dolar)

Amaç: Bozulmuş havuz durumundan astronomik miktarda yETH basmak, ardından gerçek varlıklarla takas etmek. Bu aşama, ikincil güvenlik açığının (devre dışı bırakılmamış önyükleme yolu) ve Hata Modu B'nin (taşma) birlikte bağımlı kombinasyonunu istismar ediyor ve toplam kayıpların ~%10'una katkıda bulunuyor.

1. Taşma Yoluyla Basım

Toplam arz sıfırda olduğunda, saldırgan add_liquidity()'yi toz miktarlarda (bakiye [1, 1, 1, 1, 1, 1, 1, 9]) çağırıyor.

prev_supply == 0 olduğundan, kod Bölüm 0x2'de (Temel Neden Analizi) açıklanan önyükleme yoluna giriyor: saklanan durumu atlıyor ve _calc_vb_prod_sum() aracılığıyla vb_prod ve vb_sum'u sıfırdan yeniden hesaplayarak bunları _calc_supply()'ye iletiyor. Bu, ikinci güvenlik açığının devrede olduğu andır: saldırgan havuzu başlatılmamış durumuna geri döndürerek çözücüye beslenen başlangıç koşulları üzerinde kontrol kazanıyor.

Tüm sanal bakiyeler toz düzeyinde olduğunda (döviz kurları 1e18'e yakın), hesaplanan değerler şunlar:

  • vb_sum = 16
  • vb_prod ≈ 9,13e20
  • _supply = vb_sum = 16

_calc_supply() içinde değişkenler şu şekilde başlatılıyor:

  • l = _amplification * _vb_sum ≈ 4,5e20 × 16 ≈ 7,2e21
  • d = _amplification - PRECISION4,49e20
  • s = _supply = 16
  • r = _vb_prod9,13e20

Şimdi l - s * r çıkarması:

7,2×102116×9,13×1020=7,2×10211,46×10227,4×10217,2 \times 10^{21} - 16 \times 9,13 \times 10^{20} = 7,2 \times 10^{21} - 1,46 \times 10^{22} \approx -7,4 \times 10^{21}

Bu negatiftir. İşaretsiz uint256 aritmetiğinde, unsafe_sub bunu yaklaşık 22567,4×10212^{256} - 7,4 \times 10^{21}'e, astronomik derecede büyük bir değere sarıyor. d (~4,49e20) ile bölümden sonra, elde edilen arz tahmini ~2,35e56 oluyor ve protokol bu miktarın tamamını saldırgana basıyor. Bu taşma yalnızca toplam arz 2. Aşamada sıfıra indirildiği için mümkündür; herhangi bir normal havuz durumunda l > s * r geçerlidir ve çıkarma güvenlidir.

2. Gerçek Varlıklarla Takas

Saldırgan, fazla basılan yETH'in bir kısmını yETH–WETH Curve havuzunda ~1.097e18 WETH karşılığında takas ederek WETH rezervlerini boşaltıyor. 1. Aşamada harcanan 800e18 WETH hesaba katıldığında, net kâr ~0,9 milyon dolar oluyor.

  1. Aşama sırasında çıkarılan ~8,1 milyon dolar LST varlığıyla birlikte, saldırgan flash kredileri geri ödedikten sonra toplamda yaklaşık 9 milyon dolar net kâr elde ediyor.

Fon kaynaklarını ve hedef adresleri içeren ayrıntılı fon akışı analizi, diğer yayımlanmış analizlerde ele alınmış (örneğin [2]) ve bu makalenin kapsamı dışındadır.


0x4 Yanlış Anlamaların Düzeltilmesi

Bu olayın yayımlanmış analizlerinin büyük çoğunluğu, saldırganın ön koşulları nasıl oluşturduğunu tam olarak açıklamadan aritmetik semptomlara odaklanıyor. İki özel iddianın düzeltilmesi gerekiyor.

0x4.1 İddia: "pow_up() ile pow_down() Arasındaki Yuvarlama Uyuşmazlığı Değişmezi Bozuyor"

Yaygın bir yorum, temel nedeni bazı kod yollarında pow_up(), diğerlerinde pow_down() kullanılmasına bağlayarak yönsel uyuşmazlığın istismar edilebilir tutarsızlıklar getirdiğini öne sürüyor.

Bunu doğrudan test ettik: sözleşmeyi pow_down()'u tekdüze kullanacak şekilde değiştirdik (tüm pow_up() çağrılarını değiştirerek) ve tam saldırı simülasyonunu Foundry'de yeniden çalıştırdık. İstismar aynı şekilde başarılı oldu. Çarpım hâlâ sıfıra çöküyor, arz hâlâ azalıyor ve taşma hâlâ şişirilmiş bir basıma neden oluyor.

Sıfır çarpım durumunu mümkün kılan yuvarlama, başlangıç çarpım değerlerini tahmin etmek için kullanılan üs fonksiyonlarındaki yuvarlama yönü değil, yineleme döngüsü içindeki r = unsafe_div(unsafe_mul(r, sp), s) ifadesindeki taban bölmesidir.

0x4.2 İddia: "İkinci Yinelemede Taşma, Ara Terimi Sıfırlar"

Yaygın olarak alıntılanan bir açıklama, _calc_supply()'nin ikinci yinelemesinde unsafe_sub içindeki bir taşmanın sp ≈ 1,94e18 ürettiğini ve bunun r'nin sıfıra yuvarlanmasına neden olduğunu öne sürüyor.

Hem Foundry (zincir üstü yeniden oynatma) hem de Python (matematiksel doğrulama) kullanarak tam ara değerleri yeniden ürettik. Foundry simülasyonu _calc_supply() yinelemesini adım adım izliyor:

======= _calc_supply yinelemesi 0 =======
  l = 4905875511098192451202650000000000000000
  s = 2514373972590845290489        ← başlangıç arzı
  r = 3538247433646816               ← başlangıç çarpımı (çok küçük)
  d = 4490000000000000000000

  sp = (l - s*r) / d ≈ 1.093e22     ← yeni arz ~4 kat artıyor
  new r ≈ 4.49e22                    ← çarpım dramatik biçimde şişiyor

======= _calc_supply yinelemesi 1 =======
  s = 10926206313726454855296        ← önceki sp'den
  r = 44892226765713223838396        ← önceki iç döngüden

  sp = 19113493328251743069          ← ≈ 1.91e19, meşru biçimde küçük
  new r = 0                          ← sıfıra yuvarlandı!

Kritik gözlem: 1. yinelemede sp, ~1,91e19 olarak değerlendiriliyor. Bu, taşma eseri değil meşru biçimde küçük pozitif bir değerdir. l - s*r çıkarması küçük bir pozitif sonuç üretiyor çünkü yükseltme ağırlıklı toplam l ve arz-çarpım terimi s*r, bu yinelemede büyüklük açısından birbirine yakın.

Çarpımı sıfırlayan, sonra gelen şeydir: iç döngü r = r * sp / s hesaplıyor; burada sp (~1,91e19), s'den (~1,09e22) çok daha küçük. r * sp payı, s paydasının altına düşüyor ve tamsayı bölmesi sonucu sıfıra indiriyor.

Bunu Python'da bağımsız olarak doğruladık; keyfi hassasiyetli tamsayılarla aynı değerleri hesaplayarak çıkarmanın taşmadığını doğruladık:

Çarpım, çıkarmadaki taşma yoluyla değil, bölmedeki yuvarlama yoluyla sıfırlanıyor. Arzı şişiren unsafe_sub taşması tamamen farklı bir bağlamda gerçekleşiyor: saldırının 3. Aşaması, toz likiditenin arz sıfıra indirilmiş havuza eklendiği zaman.


0x5 Sonuç

yETH istismarı, asimetrik etkiye sahip iki güvenlik açığı içeriyordu. _calc_supply() içindeki güvenli olmayan aritmetik birincil temel nedendi: aşağı yuvarlama hatası (Hata Modu A), yalnızca 2. Aşama aracılığıyla bağımsız olarak ~8,1 milyon dolar kayba neden oldu. Devre dışı bırakılmamış önyükleme yolu ikincil bir güvenlik açığıydı; taşma hatası (Hata Modu B) ile birleşerek 3. Aşamada ek ~0,9 milyon dolarlık kayba olanak tanıdı, ancak yalnızca 2. Aşama arzı sıfıra indirdikten sonra. Bu kayıp dökümü, mevcut analizi 2. Aşama ve 3. Aşama kârlarını ayırmayan diğer yayımlanmış raporlardan ayırt ediyor.

Resmi ölüm sonrası rapor [2] beş temel neden tanımlıyor. Biz bunları resmi #1 ve #5'i pekiştiren güvenli olmayan aritmetik ve #4 olarak devre dışı bırakılmamış önyükleme yolu şeklinde iki kusur ile iki mimari ön koşul (#2 asimetrik Π işleme; #3 POL etkinleştirilen sıfır-arz durumu) olarak yeniden sınıflandırıyoruz. Ayrım şu: kusurlar, tasarım amacını ihlal eden uygulama hatalarıdır (çözücü sıfır çarpımlar üretmemeli veya taşmamalıdır); ön koşullar ise tasarlandığı gibi işlev gören ancak kusurlarla birleştiğinde istismar edilebilir saldırı yüzeyi oluşturan tasarım tercihlerdir.

Öneriler

  • Değişmez çözücülerde kontrollü aritmetik. Güvenlik riski karşısında gaz verimliliği pahasına bile olsa, taşma/taşma durumunda açık geri dönüşle safe_div ve safe_sub kullanın. Çözücü en fazla 256 yineleme çalıştırır ve gaz yükü güvenlik riskine kıyasla ihmal edilebilir.
  • Ara değerlerde sınır kontrolleri. Çarpım teriminin yinelemeler arasında makul bir aralıkta kaldığını doğrulayın. Sıfıra düşen bir çarpım veya yinelemeler arasında büyüklük mertebeleri artış gösteren bir arz tahmini, bozulmuş bir duruma 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ı uygulayın. Bu, 1. Aşamanın ön koşulları oluşturmasını engellerdi.
  • Değişmez monotoniklik kontrolleri. _calc_supply() döndükten sonra, yeni arzın değişim yönüyle tutarlı olduğunu doğrulayın (likidite ekleme hiçbir zaman arzı azaltmamalı, kur güncellemeleri 10x değişiklikler üretmemelidir vb.).
  • Başlatma yollarını kalıcı olarak devre dışı bırakın. Havuzun ilk mevduatından sonra, prev_supply == 0 önyükleme dalını yeniden giriş yapılamayacak şekilde engelleyin. Bu, 3. Aşamayı tamamen önlerdi.
  • Sıfır-arz durumlarını önleyin. Protokol düzeyindeki yakmaların (POL veya staking sözleşmelerinden) havuz sıfırdan farklı bakiyeler tutarken toplam arzı sıfıra indiremeyeceğinden emin olun. Minimum bir arz alt sınırı, önyükleme yeniden girişini mümkün kılan bozulmuş duruma geçişi engellerdi.
  • Gerçek zamanlı anomali tespiti. Anormal durum geçişlerini (çarpım terimlerinin sıfıra düşmesi, arzın büyüklük mertebeleri kadar değişmesi veya kısa zaman dilimlerinde tekrarlanan ekleme/kaldırma döngüleri gibi) izleyin ve kayıplar birikmeden önce uyarılar veya devre kesiciler tetikleyin.

Referanslar

  1. Yearn Finance olay duyurusu
  2. Yearn Güvenlik ölüm sonrası raporu
  3. yETH belgeleri
  4. yETH teknik belgesi: değişmez türetimi
  5. BlockSec Explorer'da saldırı işlemi
  6. BlockSec: Balancer artırılmış havuz olayının analizi (Ağustos 2023)

BlockSec Hakkında

BlockSec, tam yığın blok zinciri güvenliği ve kripto uyumluluk sağlayıcısıdır. Müşterilerin protokollerin ve platformların tam yaşam döngüsü boyunca kod denetimi (akıllı sözleşmeler, blok zinciri ve cüzdanlar dahil), saldırıları gerçek zamanlı olarak durdurma, olayları analiz etme, yasadışı fonları izleme ve AML/CFT yükümlülüklerini yerine getirmelerine yardımcı olan ürün ve hizmetler geliştiriyoruz.

BlockSec, prestijli konferanslarda birden fazla blok zinciri güvenlik makalesi yayımlamış, DeFi uygulamalarının birçok sıfır gün saldırısını bildirmiş, 20 milyonun üzerinde doları kurtarmak için birden fazla saldırıyı engellemiş ve milyarlarca kripto parayı güvence altına almıştır.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit