En el 90% de los protocolos DeFi donde metes tus stablecoins, hay un backdoor legal metido directamente en el código: la famosa Upgradeability. La idea suena bien: arreglar bugs y optimizar gas. ¿La realidad? Con un solo clic pueden cambiar el código del contrato en producción por un script random para drenar la liquidez hasta el último centavo.
Anoche me quedé hasta las tres de la mañana revisando un fork recién salido en Base. Pensé que ya estaba cansado y viendo cosas donde no las había, pero no: es una clásica bomba de tiempo en el proxy. ¡Y el proyecto tiene auditoría! Un PDF impecable firmado por una firma top. Vamos a desglosarlo.
Engaño arquitectónico: Cómo funcionan los proxies
Para el usuario común, un smart contract es un monolito: lo despliegas y te olvidas. Pero si necesitas que sea actualizable, la arquitectura se divide en dos: el Proxy y la Lógica (Implementation). El usuario siempre interactúa con el Proxy. El Proxy no tiene lógica de negocio propia; es tonto. Su única función es redirigir las llamadas usando delegatecall.
Ahí es donde está la trampa. delegatecall es la función más peligrosa de Solidity. Ejecuta el código del contrato objetivo (la Implementación), pero en el contexto del almacenamiento (storage) del propio Proxy. Es decir, las variables se quedan en el Proxy, pero el código se trae de afuera. Si el admin cambia la dirección de la Implementación en el contrato del Proxy: pum, tienes un protocolo actualizado. O un backdoor.
Los patrones principales que te venden como seguridad:
- UUPS (UUPSUpgradeable): El slot con la dirección de la lógica está en el propio contrato de implementación. Si el admin despliega una lógica rota que olvide heredar de UUPS, el contrato se convierte en un ladrillo. Para siempre. Los fondos se quedan atrapados. ¿Irónico? Demasiado.
- Transparent Proxy Pattern (TPP): Aquí un contrato dedicado llamado
ProxyAdmingestiona la actualización. Separa los roles: los usuarios llaman a la lógica de negocio y el admin solo toca las funciones de actualización. Parece más limpio, pero consume gas como loco debido a las validaciones constantes demsg.sendera nivel de fallback. - Beacon Proxy: Un único contrato faro (Beacon) guarda la dirección de la lógica para cientos de proxies idénticos. Es superútil para colecciones NFT o pools. Cambias la dirección en el Beacon y actualizas mil contratos a la vez. ¿Práctico para un hacker? Totalmente. Un solo exploit y se cae la red entera de pools.
Anatomía de un backdoor: Cómo te van a robar los fondos
¿Crees que hace falta un exploit complejo para robar? Para nada. Al admin le basta con modificar una sola línea en la nueva implementación.
Miremos el código real. Armé rápido un ejemplo clásico de un contrato "honesto" que, con un simple movimiento, se convierte en una estafa.
Fase 1: El contrato honesto (Implementation_V1.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Un pool normal. Los inversores depositan y acumulan rendimientos.
contract VaultV1 is Initializable {
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
// Usamos initializer en lugar de constructor. Si olvidas correrlo, cualquiera puede adueñarse del contrato.
function initialize() public initializer {
admin = msg.sender; // Guarda la wallet del deployer o, idealmente, un multisig.
}
function deposit() external payable {
require(msg.value > 0, "Zero funds");
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Retiro limpio. Sin comisiones ocultas... por ahora.
function withdraw(uint256 _amount) external {
require(balances[msg.sender] >= _amount, "Low balance");
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(msg.sender).transfer(_amount);
}
}Todo limpio. Los auditores dan el visto bueno. El protocolo arranca, la liquidez sube y la comunidad celebra en los chats.
Fase 2: La actualización nocturna (Implementation_V2.sol)
Pasa un mes. Ya hay 5000 ETH en el pool. El admin (o un hacker que robó la private key) despliega la segunda versión. Llama a la función upgradeTo() en el proxy y mete la nueva dirección.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
// CRUCIAL: El storage layout debe ser exactamente idéntico al de V1.
// Si mueves una variable, rompes los mappings y se rompe todo el estado.
address public admin;
mapping(address => uint256) public balances;
uint256 public totalFunds;
address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Wallet offshore
// Dummy para evitar que el initializer se ejecute dos veces
function initialize() public initializer {}
function deposit() external payable {
balances[msg.sender] += msg.value;
totalFunds += msg.value;
}
// Aquí está el backdoor. ¿Lo vería un usuario promedio? No.
function withdraw(uint256 _amount) external {
// Por fuera parece normal, pero...
require(balances[msg.sender] >= _amount, "Low balance");
// Fee oculto: desviamos silenciosamente el 99% a shadowWallet.
// ¿Por qué no el 100%? Para que la transacción no falle de golpe; que piensen que es lag de la interfaz.
uint256 tax = (_amount * 99) / 100;
uint256 userShare = _amount - tax;
balances[msg.sender] -= _amount;
totalFunds -= _amount;
payable(shadowWallet).transfer(tax);
payable(msg.sender).transfer(userShare);
}
// O simplemente el botón de "exit rug" para el admin
function emergencyDrain() external {
require(msg.sender == admin, "Not an admin");
// Drenamos todo el balance del contrato. Adiós, liquidez.
payable(shadowWallet).transfer(address(this).balance);
}
}El usuario va a la dApp, hace clic en Withdraw y firma la transacción. La interfaz se queda cargando. Le llega el 1% de su depósito a la wallet; el resto ya voló. Vas a quejarte al grupo de Telegram, pero los mods ya están borrando los mensajes y baneando gente. Un clásico.
Colisión de slots de memoria: El backdoor oculto más avanzado
A veces el admin ni siquiera necesita meter código sospechoso como emergencyDrain(). Hay un truco más sofisticado: la colisión intencional de slots de memoria (Storage Slot Collision).
En la EVM no existen los nombres de las variables. Solo hay slots (del 0 al 2256-1) donde los datos se escriben de forma secuencial. Si cambias el orden en el que declaras las variables en la nueva implementación, puedes hacer que escribir en una variable cualquiera, como userLimit, sobrescriba la dirección del admin.
Hace poco vi un contrato donde, al actualizarlo, metieron un simple bool justo antes de la variable owner. Eso desalineó todo el storage. Cualquier usuario que llamara a la función para cambiar su propia configuración se convertía automáticamente en el dueño del contrato y podía vaciarlo. Los devs juraban que fue un error sin querer. Claro, les creemos. El drenaje fue directo a una wallet que dos días antes había estado fondeada por Tornado Cash. Pura coincidencia, seguro.
Checklist paranoico: Cómo no terminar siendo la liquidez de otro
Si crees que Etherscan te va a salvar, eres muy ingenuo. El check verde de "Verified" en un contrato proxy solo significa que el código de ese proxy está limpio. Hay que escarbar más profundo.
Aquí tienes una tabla de referencia con lo que debes revisar antes de meter en un protocolo más dinero de lo que cuesta tu almuerzo.
| Parámetro del contrato | Ideal (Safe) | Peligro (Red Flag) | Cómo verificarlo en el explorer |
|---|---|---|---|
| Tipo de contrato | Inmutable (Immutable) | Proxy (UUPS / Transparent) | Pestaña Contract -> Si aparecen los botones Read as Proxy / Write as Proxy, es actualizable. |
| Gobernanza (Admin) | Multisig (Gnosis Safe 3/5) + Timelock | EOA (una wallet normal de un solo admin) | Lee el slot de admin o owner y revisa esa dirección. Si no tiene código (no es un contrato), el proyecto depende de una sola persona. Si le roban la clave privada, te quedas en cero. |
| Timelock (Retraso temporal) | De 48 horas a 7 días | No tiene o está configurado en 0 | Revisa si las funciones de actualización pasan obligatoriamente por un contrato timelock. Si el admin puede tirar un upgradeTo de forma inmediata, sal de ahí. |
| Slot de implementación | Hasheado bajo el estándar EIP-1967 | Slot personalizado u oculto | Monitorea las migraciones de storage. Revisa la pestaña State en Etherscan cuando ocurran actualizaciones. |
Los timelocks son otra ilusión de seguridad para que los minoristas duerman tranquilos. Los devs van sacando pecho en Discord diciendo: «¡Tenemos un timelock de 48 horas! ¡Cero upgrades sorpresa!».
En teoría suena de locos. El admin tiene un margen de dos días para encolar la transacción de actualización. Eso te daría tiempo de olerte la jugada, armar revuelo y salvar tus fondos. Pero, ¿en la práctica? A todo el mundo se la suda.
¿Quién monitoriza la mempool o los eventos de timelock 24/7? Nadie. La peña duerme, trabaja o se está tomando unas cañas. El hacker o el admin estafador mete la transacción el viernes por la noche. El domingo de madrugada expira el timelock y se cambia el código. El lunes por la mañana te despiertas con el balance a cero y cara de tonto. El desfase temporal solo te salva si tienes alertas automatizadas (tipo Defender Sentinel o Tenderly) y bots listos para un withdraw de emergencia. ¿No tienes bot? Te quedarás mirando cómo vuela tu pasta mientras esperas en la cola del matadero.
Toca hablar de esa mierda oculta que apenas se menciona en las auditorías estándar.
Minas arquitectónicas: Métodos ocultos de inicialización
Al desplegar un contrato normal, se ejecuta el constructor. Corre una sola vez, guarda las variables necesarias en el storage y desaparece. En arquitecturas proxy, el constructor de la implementación no toca el storage del proxy. Por eso se usa una función inicializadora, como el método initialize de arriba.
Ahí es donde empieza la magia de la ingeniería de backdoors. ¿Qué pasa si el admin se dejó una segunda función de inicialización? ¿O un método oculto de re-initialization?
OpenZeppelin incluye el modificador reinitializer(uint8 version). Sirve para inicializar variables nuevas al actualizar a la V2. Si el dev se monta su propia película o se le «olvida» proteger esta función de reconfiguración, cualquiera podría sobreescribir variables críticas.
Ejemplo de código de migración vulnerable (visto para sentencia o metido aposta):
// 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;
// Variables nuevas para V3
bool public isPaused;
address public trustedRecoveryAddress;
// Reinitializer para el upgrade.
// Ojo a esta linea. ¿Ves la cagada?
function upgradeConfig(address _recovery) external {
// Falta el require(msg.sender == admin, "Not admin");
// O han metido logica que resetea el estado de inicializacion
trustedRecoveryAddress = _recovery;
// Regalito para el dev:
admin = msg.sender; // ¡Boom! Cualquiera ejecuta esto y se adueña del contrato
}
}Dirás: «Es un bug demasiado cantoso, saltaría a la vista». Ni de coña. Se camufla tras fórmulas matemáticas raras o dentro de librerías de terceros que se importan in deploy. La función parece un cálculo de rendimientos inofensivo, pero por detrás te mete un overwrite directo en el slot del admin.
Inspección on-chain: Lectura directa de slots
Si el equipo va a meter un rug pull, no va a verificar el código del backdoor en Etherscan. Desplegarán la implementación directamente en bytecode sin verificar. En el explorer solo verás un chorro de hex. Da mal rollo, claro.
Para saber a dónde apunta el proxy ahora mismo, hay que ir a la raíz: los slots de memoria de la EVM. Según el estándar EIP-1967, la dirección de la lógica debe estar en un slot fijo para evitar colisiones de storage.
Dirección del slot de implementación (EIP-1967):bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Lo que nos deja este hash: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
Sabiendo usar eth_getStorageAt, te da igual si el contrato está verificado o no. Metes la dirección del proxy, consultas ese slot y obtienes la dirección hex real del contrato lógico actual. Si esa dirección cambia sin avisar, saca los fondos cagando leches.
# Ejemplo de consulta RPC vía curl para auditar el contenido real del slot
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}'La respuesta devuelve 32 bytes. Los últimos 20 bytes son la dirección real del contrato que controla tu pasta en este preciso milisegundo. Olvídate de lo que pinte la interfaz del proyecto; esa dirección es la que ejecutará el delegatecall.
¿Cuál es la conclusión?
Grábate esto: la upgradeability siempre es un pacto con el diablo entre la flexibilidad del dev y la seguridad del inversor. Si un proyecto farda de miles de millones en TVL (Total Value Locked), pero usa un proxy con un multisig 2 de 3 sin timelock, ese dinero no es de los inversores. Pertence a los tres tipos que tienen las llaves. O al primer hacker que se la cuele con un phishing.
Si el contrato es modificable, estás confiando en personas, no en código. Y la historia de las criptos demuestra que la gente flaquea, se deja chantajear o pierde la cabeza en cuanto ve cifras con más de seis ceros.