Back to Blog

#4: Curve Olayı: Derleyici Hatası, Zararsız Kaynak Koddan Hatalı Bytecode Üretiyor

Code Auditing
February 14, 2024
9 min read

30 Temmuz 2023'te bir dizi istismar, birden fazla Curve havuzunu hedef alarak milyonlarca dolar kayba yol açtı. Bu, tipik olmayan bir kök nedene sahip tipik bir yeniden giriş saldırısıdır; söz konusu kök neden, yeniden giriş korumasının yokluğuna yol açan bir derleyici hatasından kaynaklanmaktadır. Özellikle, bir akıllı sözleşme içindeki farklı fonksiyonlar için yeniden giriş kilitlerinin farklı depolama alanlarına atandığı bir hata mevcuttur. Sonuç olarak, Vyper 0.2.15, 0.2.16 ve 0.3.0 sürümleri kullanılarak derlenen akıllı sözleşmeler bu açığa karşı savunmasızdı.

Arka Plan

Curve, akıllı sözleşme geliştirme için Solidity yerine Vyper kullandığından, ilgili güvenlik açığının anlaşılmasına yardımcı olmak amacıyla Vyper diline kısa bir giriş yapılmaktadır.

Vyper, Ethereum'un kurucu ortağı Vitalik Buterin tarafından oluşturulan Python tabanlı bir programlama dilidir. Belgelerinde belirtildiği üzere Vyper, Ethereum Sanal Makinesini (EVM) hedef alan, sözleşme odaklı, alana özgü, Pythonumsu bir programlama dilidir. Hedefleri arasında sadelik, 'Pythonculuk', güvenlik ve denetlenebilirlik yer almaktadır.

Vyper, Ethereum ve EVM uyumlu zincirler için iyi bilinen Solidity'nin ardından en yaygın kullanılan ikinci programlama dili hâline gelmiştir. Curve, Vyper dilinin en büyük kullanıcılarından biridir ve sözleşmelerinin büyük çoğunluğu bu dilde yazılmıştır. Curve ile ilgili veya Curve'den çatallanmış pek çok proje de Curve sistemleriyle daha iyi kod yeniden kullanımı ve birlikte çalışabilirlik sağlamak amacıyla Vyper kullanmaktadır.

Aşağıda bir Curve havuzundan (bu olayda saldırıya uğrayan pETH/ETH havuzu) alınan bir kod parçası yer almaktadır. Söz dizimi Python'a çok benzese de Vyper ile Python arasında dikkat çekici farklılıklar bulunmaktadır:

@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice Withdraw coins from the pool
    @dev Withdrawal amounts are based on current deposit ratios
    @param _burn_amount Quantity of LP tokens to burn in the withdrawal
    @param _min_amounts Minimum amounts of underlying coins to receive
    @param _receiver Address that receives the withdrawn coins
    @return List of amounts of coins that were withdrawn
    """
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

    for i in range(N_COINS):
        old_balance: uint256 = self.balances[i]
        value: uint256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value

        if i == 0:
            raw_call(_receiver, b"", value=value)

Sözleşme içindeki bir fonksiyonun tüm parametreleri ve dönüş değerleri uygun şekilde tür açıklamasıyla belirtilmelidir. Dil, sınıfları veya kalıtımı desteklememekte; 'self' anahtar sözcüğü yalnızca sözleşmenin kendisine atıfta bulunmak ve durum değişkenlerine erişmek için kullanılmaktadır. Bir fonksiyonun özelliklerini belirtmek amacıyla yerleşik dekoratörler (örn. @external ve @nonreentrant) kullanılmakta olup özel dekoratör desteği bulunmamaktadır.

Güvenlik Açığı Analizi

Güvenlik açığı, yeniden giriş korumasının eksikliğiyle sonuçlanan bir derleyici hatasından kaynaklanmaktadır.

Yeniden giriş saldırıları, blok zinciri ekosisteminde en yaygın saldırı türlerinden biridir. Özellikle, sözleşme mantığının yürütülmesinin harici çağrılar başlattığı ve bu çağrıların bir kısmının özgün sözleşmeye özyinelemeli olarak geri çağrı yaptığı durumlarda ortaya çıkarlar. Bu durum, fonksiyon yürütme sırasında sözleşmenin ara durumunu diğer sözleşmelere tehlikeli biçimde açabilir ve potansiyel güvenlik açıklarına yol açabilir. Bununla mücadele etmek için, tek bir işlemin yürütülmesi sırasında sözleşmeye yeniden girilememesini sağlamak amacıyla bir yeniden giriş koruyucusu veya kilidi kullanılır.

Daha önce bahsedilen kod parçasında, @nonreentrant('lock') açıklaması, remove_liquidity fonksiyonunun lock adlı yeniden giriş kilidiyle güvence altına alınması gerektiğini belirtmektedir. Daha fazla netlik sağlamak adına bu durum, OpenZeppelin ReentrancyGuard sözleşmesi ve onun nonReentrant değiştiricisiyle karşılaştırılabilir. Vyper'daki temel fark, yeniden giriş kilitlerinin harici bir kütüphane tarafından sağlanmayıp dilin yerleşik özellikleri olmasıdır. Bu durum, yeniden giriş kilitlerinin uygulanmasına daha derinlemesine bakılana kadar yeterli görünüyordu. Pull Request #2391'de (23 Temmuz 2021'de birleştirilen) tanıtılan kod parçasının, Vyper kaynak kodunun AST'sine (Soyut Söz Dizimi Ağacı) dayalı olarak depolama alanlarını atamak için set_storage_slots fonksiyonunu kullandığı tespit edildi.

def set_storage_slots(vyper_module: vy_ast.Module) -> None:
    """
    Parse module-level Vyper AST to calculate the layout of storage variables.
    """
    # Allocate storage slots from 0
    # note storage is word-addressable, not byte-addressable
    storage_slot = 0

    for node in vyper_module.get_children(vy_ast.FunctionDef):
        type_ = node._metadata["type"]
        if type_.nonreentrant is not None:
            type_.set_reentrancy_key_position(StorageSlot(storage_slot))
            # TODO use one byte - or bit - per reentrancy key
            # requires either an extra SLOAD or caching the value of the
            # location in memory at entrance
            storage_slot += 1

Ancak kritik hata şurada yatmaktadır: her yeniden giriş kilidi için depolama alanı 1 artırılmaktadır. Sonuç olarak, farklı fonksiyonlar yeniden giriş kilitleri için benzersiz depolama alanlarına atanmaktadır. Bu uygulamayı değerlendirmek için aşağıdaki sözleşmeyi derliyoruz:

# Compile command: vyper -f bytecode,bytecode_runtime,ir test_contract.vy

from vyper.interfaces import ERC20

test_addr: address

@external
def __init__(_addr: address):
    self.test_addr = _addr

@external
@nonreentrant('lock')
def test_funcA():
    pass

@external
@nonreentrant('lock')
def test_funcB():
    pass

Vyper Derleyici sürümü 0.2.16+commit.59e1bdd kullanıldığında, kod derleme sırasında oluşturulan Vyper IR (Ara Temsil) kısmen aşağıdaki gibidir:

[if,
  [eq, _func_sig, 2354224227 <test_funcA()>],
  [seq,
    [assert, [iszero, [sload, 0]]],
    [sstore, 0, 1],
    pass,
    # Line 13
    pass,
    # Line 12
    [sstore, 0, 0],
    stop]],
# Line 17
[if,
  [eq, _func_sig, 741100118 <test_funcB()>],
  [seq,
    [assert, [iszero, [sload, 1]]],
    [sstore, 1, 1],

4-5. ve 16-17. satırlardaki IR'ye odaklanalım; burada oluşturulan kod yeniden giriş kilidini doğrular ve kilit durumunu depolama alanında saklar. Ancak farklı fonksiyonların yeniden giriş kilidi için farklı alanlar kullandığı gözlemlendi: test_funcA, 0. alanı kullanırken test_funcB, 1. alanı kullanmaktadır. Bu durum, sözleşmeye farklı fonksiyonlar aracılığıyla yeniden girilebileceğinden yeniden giriş kilidinin etkisiz olduğunu göstermektedir.

Saldırı Analizi

Burada Curve hakkında bağlam bilgisi sunulmaktadır. Bir Curve havuzu, kullanıcıların add_liquidity ve remove_liquidity fonksiyonları aracılığıyla likidite sağlamasına ve çekmesine olanak tanır. Likidite eklerken eklenecek miktar, toplam arzın bir oranıyla belirlenir; özellikle eklenen likiditenin mevcut likiditeye oranıyla. Öte yandan, remove_liquidity, sunulan LP (Likidite Sağlayıcı) tokenlarının mevcut toplam arza oranına göre çekilecek token sayısını hesaplar ve ardından LP tokenları yakılır.

Ayrıca Curve, yerel tokenları işleyen havuzları destekler ve yerel tokeni kullanıcıya iade etmek için düşük seviyeli çağrılar (Vyper'daki raw_call fonksiyonu) kullanır. Aşağıdaki kod parçasında, remove_liquidity fonksiyonu önce LP token miktarına ve toplam arza göre kaldırılacak tokenları hesaplar ve aktarır, ardından toplam arz azaltılır.

Normal koşullar altında bu güvenli olurdu, çünkü yeniden giriş kilidi ham çağrılar sırasında ara durumun açığa çıkmasını önlemelidir. Ancak yeniden giriş kilidi etkisiz olduğunda—sonunda istismar edilen bu kusur—bir saldırı mümkün hâle gelir. Etkisiz yeniden giriş kilidi, ara durumun (çekilecek tokenların aktarıldığı ancak toplam arzın henüz azaltılmadığı durum) düşük seviyeli bir çağrı sırasında savunmasız hâle gelmesi anlamına gelir ve sözleşmeye potansiyel yeniden girişe olanak tanır.

@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice Withdraw coins from the pool
    @dev Withdrawal amounts are based on current deposit ratios
    @param _burn_amount Quantity of LP tokens to burn in the withdrawal
    @param _min_amounts Minimum amounts of underlying coins to receive
    @param _receiver Address that receives the withdrawn coins
    @return List of amounts of coins that were withdrawn
    """
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

    for i in range(N_COINS):
        old_balance: uint256 = self.balances[i]
        value: uint256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value

        if i == 0:
            raw_call(_receiver, b"", value=value)
        else:
            response: Bytes[32] = raw_call(
                self.coins[1],
                concat(
                    method_id("transfer(address,uint256)"),
                    convert(_receiver, bytes32),
                    convert(value, bytes32),
                ),
                max_outsize=32,
            )
            if len(response) > 0:
                assert convert(response, bool)

    total_supply -= _burn_amount
    self.balanceOf[msg.sender] -= _burn_amount
    self.totalSupply = total_supply
    log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)

    log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)

    return amounts

Bu yeniden giriş, yukarıdaki 27. satırda belirtildiği üzere toplam arzın henüz azaltılmamış olması nedeniyle istismar edilebilmektedir. Bu noktada sözleşmeye yeniden girip add_liquidity çağırırsak, likidite sağlama işlemi yanlış bir toplam arza (olması gerekenden daha yüksek) dayalı olacak ve bu durum aşırı miktarda LP tokenının basılmasına ve havuzun zarar görmesine yol açacaktır. Bu olayda gerçekleşen saldırıların büyük çoğunluğu bu güvenlik açığından yararlanmıştır. Aşağıdaki tartışma, Curve pETH-ETH havuzuna yönelik en büyük saldırı işlemlerinden biri olan 0xa84aa065ce'yi incelemektedir.

Saldırı izi çok açıktır.

  1. Saldırgan, Balancer'dan bir flaş kredi alarak Curve pETH/ETH havuzuna likidite olarak 40.000 ETH sağladı ve karşılığında 32.431,41 pETH-ETH LP tokeni aldı.
  2. Saldırgan, 32.431,41 pETH/ETH havuz LP tokenını yakarak havuzdan 3.740 pETH ve 34.316 ETH çekti.
  3. Likidite çekme işlemi sırasında havuz sözleşmesine yeniden girildi. Geri dönüş fonksiyonu içinde saldırgan, Curve pETH/ETH havuzuna likidite olarak yeniden 40.000 ETH sağladı ve ek olarak 82.182 LP tokeni kesti. Bu süreçte kullanılan toplam arz rakamı, likidite çekimi öncesine aitti; bu yanlış değer, olması gerekenden daha fazla LP tokenının basılmasıyla sonuçlandı.
  4. Ardından saldırgan, 10.272,84 Curve LP tokenını yakarak 1.184,73 pETH ve 47.506,53 ETH çekti. Özetle, saldırgan fazladan LP tokeni basarak ve bu ek LP tokenlarını kullanarak havuzu boşaltmak suretiyle kâr elde etti.

Özet

Bu güvenlik açığı, kaynak koddan değil derleyiciden kaynaklanmıştır. Bu, blok zinciri ekosisteminde bir derleyici hatasının önemli finansal kayba yol açtığı ilk olaydır.

Derleyiciler kritik bir altyapı bileşeni oluşturduğundan, güvenlikleri blok zinciri teknolojisinin bütünlüğü ve işlevselliği açısından büyük önem taşımaktadır. Derleyiciyle ilgili sorunlar hemen göze çarpmayabilir; ancak kapsamlı ve ciddi sonuçlara yol açabilirler. Derleyicilerin güvenliğinin sağlanması, güvenlik açıklarını ortaya çıkarmak ve gidermek için kapsamlı denetimler ve güçlü hata ödül programları da dahil olmak üzere titiz değerlendirmeler gerektirmektedir. Derleyici hatalarının doğasındaki incelik, bunların tespit edilmesini ve azaltılmasını karmaşık hâle getirmektedir. Bu karmaşıklık, DeFi protokollerini etkin biçimde korumak için temel otomatik savunmalar sunan BlockSec'in BlockSec Phalcon'u gibi gelişmiş saldırı tespit ve önleme mekanizmalarının önemini vurgulamaktadır.

Bu serideki diğer makaleleri okuyun:

Best Security Auditor for Web3

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

BlockSec Audit