Durante a última semana (31/08/2026 - 06/09/2026), observamos 4 incidentes de segurança com uma perda total estimada de aproximadamente $9,4M.
| Data | Incidente | Tipo | Perda Estimada |
|---|---|---|---|
| 2026/08/31 | Ankr FLOW | Validação de Estado Falha | ~$410K |
| 2026/08/31 | Aquifer | Validação de Entrada Falha | ~$2,47M |
| 2026/08/31 | Injective | Validação de Denominação Ausente | ~$4,8M |
| 2026/09/03 | Notional Finance | Conversão Insegura de Tipo | ~$1,73M |
O Melhor Auditor de Segurança para Web3
Valide o design, o código e a lógica de negócios antes do lançamento
Destaque da Semana: Injective
Este incidente é o destaque da semana pela complexidade de sua cadeia de ataque e pelo tamanho da perda: dois defeitos separados precisaram se alinhar antes que uma única liquidação pudesse resultar em pagamento. Ambos residiam na própria lógica de exchange da chain, e não em um contrato de aplicação. Isso mostra como um identificador montado a partir de campos concatenados sem separadores pode mesclar silenciosamente dois objetos que nunca deveriam estar relacionados, e o quanto essa fusão custa quando o caminho de liquidação nunca verifica se um fundo detém a denominação do mercado que ele respalda.
Em 31/08/2026, a lógica de opções binárias dentro do módulo exchange da Injective foi explorada por aproximadamente $4,8M em USDC. A Injective é uma chain de camada 1 que integra uma exchange de order book diretamente na própria chain, de modo que o código afetado faz parte do software do node, e não de um contrato implantado por alguém. Um fundo de seguro contendo INJ, o token nativo da Injective, acabou vinculado a um mercado de opções binárias cotado em USDC porque os dois identificadores colidiram, e o caminho de liquidação que recorre a um fundo de seguro nunca comparou os dois. O atacante negociou contra suas próprias subcontas dentro de um mercado desse tipo para fabricar um déficit, que o protocolo então cobriu com um saldo de INJ no valor de uma fração de centavo. Todas as posições foram reembolsadas integralmente, e o atacante sacou muito mais do que havia depositado.
Contexto
O módulo exchange da Injective lista mercados de opções binárias, que são apostas totalmente colateralizadas sobre um resultado do tipo sim-ou-não. Qualquer um pode listar um mercado desses pagando uma taxa de listagem, escolhendo o oráculo que resolverá a aposta e os timestamps de expiração e liquidação. O oráculo é nomeado como um provedor mais um símbolo: tornar-se um provedor exige uma votação de governança, enquanto o símbolo é qualquer string fornecida por quem faz a listagem. Um trader primeiro deposita tokens de cotação, como USDC, em uma subconta, e então escolhe um lado ao fazer um pedido. Uma ordem de COMPRA aposta que o evento acontecerá e trava P * Q como margem; uma ordem de VENDA aposta que não acontecerá e trava (1 - P) * Q, onde Q é a quantidade em contratos e P é o preço de entrada no intervalo [0, 1]. O lado que aposta no resultado mais provável, portanto, deposita a margem maior. O EndBlocker, o gancho que a chain executa ao final de cada bloco, casa uma COMPRA e uma VENDA no mesmo preço em uma posição LONG e uma posição SHORT. Como as duas travas sempre somam exatamente Q, um book recém-casado é totalmente financiado por construção.
Na expiração, o oráculo publica um preço de liquidação S em [0, 1] e cada posição é paga com margin ± (S - entry) * Q a partir do pool do mercado. Os pagamentos entre participantes são estritamente de soma zero, e cada pagamento tem piso zero, de forma que uma posição nunca pode ficar negativa e nunca precisa de liquidação forçada. Uma posição também pode ser encerrada antecipadamente com um pedido oposto com margin = 0, o que libera sua margem mais seu lucro realizado, pago a partir da margem que quem abre a nova posição trava. A margem de cada posição permanece no valor travado na entrada, então, após um encerramento antecipado, as margens registradas não precisam mais somar o total do pool.
Um mercado cujo símbolo nenhum provedor publica chega à liquidação sem preço algum. O módulo tem um mecanismo de fallback para esse caso: a liquidação recai sobre um caminho de reembolso implementado por getBinaryOptionsSocializedLossDataWithRefundFlag(), que desfaz o mercado em vez de resolvê-lo. Cada posição é simplesmente reembolsada em sua margem, porque o caminho encerra cada uma pelo seu próprio preço de entrada, de forma que nenhuma posição registra lucro ou prejuízo, desde que o book ainda contenha o que as posições reivindicam. Os reembolsos são financiados pelo saldo do mercado e, em caso de déficit, pelo fundo de seguro associado a esse mercado.
Um mercado e um fundo de seguro são objetos separados, cada um criado por sua própria mensagem e cada um carregando uma denominação: um mercado é cotado em um token, e um fundo detém o token com o qual foi criado. Eles são pareados por identidade: um fundo respalda o mercado cujo ID seja igual ao seu próprio. Ambos os IDs são digests keccak256 de campos de identidade. O próprio módulo exchange é uma única conta bancária unificada cujos saldos por mercado e por fundo são registros contábeis de números inteiros brutos.
Análise da Vulnerabilidade
O componente com falha é o tratamento de opções binárias no módulo exchange do injective-core, corrigido no commit b994d6b6 [1]. Dois defeitos encadeados permitem que um fundo que detém uma denominação respalde um mercado cotado em outra.
Defeito 1: os identificadores são derivados de uma concatenação sem separadores. NewBinaryOptionsMarketID(), que calcula o ID de um mercado quando esse mercado é lançado, e CreateInsuranceFund(), que calcula o ID do mercado que um novo fundo pretende respaldar (para expiry = BinaryOptionsExpiryFlag = -2), derivam ambos esse ID da mesma expressão:
return crypto.Keccak256Hash([]byte((BINARY_OPTIONS_MARKET_ID_PREFIX +
oracleType.String() + ticker + quoteDenom + oracleSymbol + oracleProvider)))
Não há separadores de campo nem prefixos de comprimento, então os limites entre os campos não deixam nenhum rastro nos bytes com hash. CreateInsuranceFund() mapeia os próprios campos de um fundo de seguro para esses slots, com oracle_base ocupando o slot oracleSymbol e oracle_quote o slot oracleProvider. Uma tupla de fundo e uma tupla de mercado podem, portanto, produzir preimagens idênticas byte a byte enquanto dividem esses bytes entre campos de forma diferente, e os dois objetos passam então a compartilhar um único ID. O registro aceita esse ID compartilhado como o vínculo entre eles, de modo que um fundo pode se tornar o fundo de seguro de um mercado cujo quoteDenom ele não detém.
Defeito 2: os pagamentos nunca são verificados contra a denominação do mercado. PayDeficitFromInsuranceFund() move moedas brutas para fora do fundo na denominação que o fundo detém, e então credita o mesmo número inteiro bruto ao saldo do mercado, sem nunca comparar insuranceFund.DepositDenom com a denominação de cotação do mercado. Como a contabilidade do módulo é feita em números inteiros simples, uma unidade bruta de INJ e uma unidade bruta de USDC são indistinguíveis nesse caminho, ainda que o mesmo número inteiro represente valores que diferem por um fator na ordem de 10^11. O commit de correção adiciona a verificação que faltava nesse caminho, tanto no lado de entrada quanto no de saída, de modo que um fundo só pode respaldar mercados cotados na denominação que ele detém:

O mesmo commit também desativa permanentemente a negociação e a liquidação de opções binárias na mainnet da Injective, eliminando o caminho de reembolso como superfície de ataque.
Análise do Ataque
Todas as etapas foram executadas por uma única carteira através de três de suas próprias subcontas (...037c, ...037d e ...037e), com Q = 15.930.
O par colidente foi construído deslocando onde os limites dos campos caem, mantendo idênticos os bytes concatenados. O ticker e o quoteDenom do fundo (X e inj) formam o ticker do mercado Xinj, e o oracle_base do fundo (um endereço de contrato e um símbolo de oráculo colados) forma o quoteDenom do mercado seguido de seu oracleSymbol:
| Slot na concatenação | Fundo (MsgCreateInsuranceFund) |
Mercado (MsgInstantBinaryOptionsMarketLaunch) |
|---|---|---|
| prefixo | -BINARY-OPTIONS-MARKET- |
-BINARY-OPTIONS-MARKET- |
oracleType.String() |
Provider |
Provider |
ticker |
X |
Xinj |
quoteDenom |
inj |
erc20:0xa00C...235a |
oracleSymbol |
erc20:0xa00C...235aNO_PRICE_FOR_REFUND...297, preenchido a partir do oracle_base do fundo com ambas as partes empacotadas nesse único campo |
NO_PRICE_FOR_REFUND...297 |
oracleProvider |
Frontrunner, preenchido a partir do oracle_quote do fundo |
Frontrunner |
Ambos resultam em 0x9793a39f82993cdedb0c82c614102d5aba2451f29709dc9a6f7df191020b4efc. O oráculo chamado NO_PRICE_FOR_REFUND foi configurado para nunca publicar um preço, o que força a liquidação para o caminho de reembolso.
A análise a seguir se baseia na transação 0x6ae9cb...51dcf8.
- Etapa 1: Em uma única transação atômica no bloco
181024772, o atacante criou o fundo de seguro colidente denominado emINJe o mercado de opções binárias denominado emUSDC, alimentou o fundo com12.744.000.000unidades brutas deINJ(no valor de cerca de$0,000000063) e depositou30.267,02 USDCdistribuídos entre as três subcontas. O depósito ínfimo foi dimensionado de modo que seu número inteiro bruto correspondesse ao déficit que o atacante planejava fabricar. Cada subconta recebeu exatamente a margem de que precisaria depois:
| Subconta | Depósito (USDC) |
Margem que financia |
|---|---|---|
037d |
1.593,001593 |
COMPRA de 15.930 a 0,10, travando 1.593 (Etapa 2) |
037c |
14.337,001593 |
VENDA de 15.930 a 0,10, travando 14.337 (Etapa 2) |
037e |
14.337,014337 |
COMPRA de 15.930 a 0,90, travando 14.337 (Etapa 3) |
| Total | 30.267,017523 |
- |
-
Etapa 2: Na mesma transação,
037dfez uma COMPRA de15.930a0,10e037cfez uma VENDA de15.930a0,10. O EndBlocker as casou em uma posição LONG para037dcarregando1.593de margem e uma SHORT para037ccarregando14.337. A margem total no book é1,0Q = 15.930, exatamente o que o pool do mercado detém, de modo que o book é indistinguível de qualquer mercado normal totalmente colateralizado. -
Etapa 3: Dois blocos e 1,1 segundos depois, na transação 0x012c17...2af694 no bloco
181024774,037dencerrou sua posição long a0,90commargin = 0e recebeu1.593 + (0,90 - 0,10) * 15.930 = 14.337. Esse pagamento saiu da margem travada pelo abridor de posição037e, que fez uma COMPRA de15.930a0,90e travou14.337. O lucro realizado de0,8Q = 12.744agora está no saldo disponível de037d, fora do pool do mercado, enquanto as margens das duas posições restantes permanecem no book: os passivos somam1,8Q = 28.674contra um pool que ainda detém apenas1,0Q. -
Etapa 4: A liquidação é acionada pelo próprio relógio do mercado, não por uma transação. No início de cada bloco, o módulo identifica qualquer mercado cujo timestamp de liquidação tenha passado e o liquida como um todo, em vez de posição por posição. Este mercado disparou 18 segundos após sua criação, com ambas as posições ainda no book: a SHORT de
037ce a LONG de037e,14.337de margem cada uma. O oráculo permaneceu em silêncio, então o caminho de reembolso calculou passivos de1,8Q = 28.674contra ativos idealizados de1,0Q = 15.930e reportou um déficit de0,8Q = 12.744. Esse déficit existe apenas dentro da contabilidade do caminho de reembolso. Sob um único preçoS, o pagamento de cada lado depende apenas deSe não de onde entrou: a posição short recebe(1 - S) * Qe a longS * Q, somando exatamente oQpresente no pool. O caminho de reembolso paga com base no preço de entrada de cada lado, então as entradas não se cancelam: a short foi paga com(1 - 0,10) * Qe a long com0,90 * Q,14.337cada uma. A short recebeu integralmente sua margem, como se o preço nunca tivesse se movido de0,10, o mesmo0,8Qque o atacante já havia sacado na Etapa 3. -
Etapa 5:
PayDeficitFromInsuranceFund()liquidou o déficit movendo12.744.000.000unidades brutas deINJpara fora do fundo colidente e creditando12.744 USDCao pool do mercado. Com o déficit reportado como coberto, o corte por perda socializada nas posições restantes foi ignorado, e cada posição foi reembolsada integralmente em sua margem. O déficit em si não custa nada a ninguém: é coberto pelo fundo de seguro do mercado ou, na falta dele, pelo corte de perda. O que tornou isso lucrativo foi o fato de o fundo vinculado ao mercado deterINJínfimo em vez deUSDC. -
Etapa 6: Na transação 0xcb33ad...152eff no bloco
181024803, o atacante sacou43.010.985.663unidades brutas deUSDC, ou43.010,99 USDC, contra os30.267,02 USDCdepositados. O ganho líquido é de12.743,97 USDC, em aproximadamente 21 segundos desde a primeira até a última transação.
O ciclo acima é uma rodada representativa. O atacante o repetiu em 299 mercados de opções binárias de curta duração, criados ao longo de uma janela de 19 horas, cada um vinculado a um oráculo configurado para nunca publicar um preço e cada um com timestamps de expiração e liquidação separados por segundos [2]. Os ganhos líquidos dessas rodadas somam os aproximadamente $4,8M perdidos no incidente.
Conclusão
Este incidente combina uma colisão de identificadores com a ausência de verificação de denominação no caminho de pagamento. Um fundo deve respaldar o mercado cuja identidade corresponde à sua. Mas um ID construído concatenando campos de identidade de ponta a ponta deixa de registrar onde um campo termina e o próximo começa, de modo que dois conjuntos diferentes de campos podem produzir o mesmo ID, e o pareamento vincula um fundo a um mercado que ele na verdade não corresponde. Nada mais adiante detecta essa incompatibilidade, porque o caminho que recorre a um fundo de seguro para cobrir o déficit de um mercado compara valores, mas nunca denominações. O atacante usou os dois defeitos em conjunto para fabricar um déficit fantasma dentro de um mercado que controlava, liquidá-lo com um saldo de fundo no valor de uma fração de centavo, e sair com o reembolso integral de margens que o pool nunca chegou a deter.
De forma mais geral, um identificador que carrega significado deve ser derivado de uma codificação que preserve a estrutura, com separadores explícitos ou prefixos de comprimento em todo campo de tamanho variável, de modo que duas tuplas de campos distintas não possam mapear para o mesmo digest.
Comece a Usar o Phalcon Explorer
Mergulhe nas Transações para Agir com Sabedoria
Experimente grátis agoraMais Incidentes Desta Semana
Ankr FLOW
Em 31/08/2026, o serviço de liquid staking da Ankr no Flow EVM foi explorado. A Ankr emite dois tokens diferentes contra o FLOW em staking, o token nativo da rede, e cada um é cunhado através de seu próprio ponto de entrada. Um desses caminhos havia sido desativado, mas uma segunda via de acesso a ele contornava a verificação que impunha a pausa, e a taxa de conversão nesse caminho havia ficado desatualizada nesse período. O atacante cunhou tokens por esse caminho a um custo muito menor do que esses mesmos tokens poderiam ser resgatados, e então fez circular a diferença através do buffer de resgate da Ankr, de um pool da Uniswap V3 e do protocolo de empréstimos MORE Markets. Cerca de 15,5M WFLOW (FLOW empacotado), no valor aproximado de $410K na época, foram drenados da reserva do MORE Markets, e o atacante obteve cerca de $246K após o slippage [3].
Contexto
Ankr FLOW é um serviço de liquid staking no Flow EVM. O FlowStakingPool encaminha FLOW para o Cadence para staking de validadores e representa as posições resultantes por meio de dois tokens: o token de certificado sem rebase ankrFLOW, e o token portador com rebase aFLOWEVMb, que por sua vez é respaldado por ankrFLOW.
Os dois tokens têm pontos de entrada separados. O caminho do certificado passa por stakeCerts() e unstakeCerts() até _stakeCerts() e _unstakeCertsFor(); o caminho do token portador passa por stakeBonds() e unstakeBonds() até _stakeBonds() e _unstakeBondsFor(). A cunhagem no caminho do token portador tem um segundo ponto de entrada externo, stakeBondsWithCode(), que recebe um código de parceiro para o programa de indicação da Ankr e depois chama a mesma função interna _stakeBonds(). Cada caminho lê sua própria taxa a partir do InternetBondRatioFeed, o contrato que publica a proporção de conversão entre FLOW e cada token.
O FlowStakingPool também mantém um buffer de FLOW para resgates imediatos. Além do pool, o ankrFLOW era negociado em um pool ankrFLOW/WFLOW da Uniswap V3 e era aceito como colateral no MORE Markets, um protocolo de empréstimo no estilo Aave V3. O MORE Markets limita o empréstimo a uma razão empréstimo/valor (LTV) definida por ativo e oferece categorias de e-mode (modo de eficiência): grupos de ativos cujo preço deve se mover em conjunto, para os quais um LTV maior se aplica assim que um tomador ativa a categoria.
Análise da Vulnerabilidade
O contrato com falha é o FlowStakingPool (0xfe81...287a), que cunha o token de certificado ankrFLOW (0x1b97...14bdb) e o token portador aFLOWEVMb (0xd6fd...f8d4a) contra as taxas lidas do InternetBondRatioFeed (0x3201...de38f).
Dois defeitos se sobrepõem. Primeiro, stakeBondsWithCode() chega a _stakeBonds() sem o modificador bondStakingUnpaused que stakeBonds() impõe, de modo que o caminho do token portador permaneceu acessível mesmo após ter sido desativado. Segundo, ao longo de 71 lotes semanais de atualização de taxas entre 29 de abril de 2025 e 27 de agosto de 2026, apenas a entrada ativa de ankrFLOW foi atualizada, deixando a entrada de aFLOWEVMb estagnada em 1,0.
As duas taxas, portanto, precificavam o mesmo stake subjacente de forma diferente:
| Direção | Funções | Taxa | Conversão |
|---|---|---|---|
| Cunhagem | _stakeCerts() / stakeCerts() |
0,833437 | 1 FLOW para 0,833437 ankrFLOW |
| Cunhagem | _stakeBonds() / stakeBondsWithCode() |
1,0 | 1 FLOW para 1 aFLOWEVMb |
| Resgate | _unstakeCertsFor() / unstakeCerts() |
0,833437 | 1 ankrFLOW para ~1,19985 FLOW |
| Resgate | _unstakeBondsFor() / unstakeBonds() |
1,0 | 1 aFLOWEVMb para 1 FLOW |
Como o aFLOWEVMb é respaldado por ankrFLOW, cunhar através do caminho do token portador produzia um ankrFLOW respaldado por FLOW depositado, enquanto o caminho do certificado produzia 0,833437. O resgate através do caminho do certificado ainda pagava ~1,19985 FLOW por ankrFLOW.
Análise do Ataque
A análise a seguir se baseia na transação 0x2b2e6e...3f66c9.
-
Etapa 1: O atacante fez o bootstrap de capital com uma única ida e volta entre os dois caminhos. Ele tomou um flash loan de
5.000 ankrFLOWdo poolankrFLOW/WFLOWda Uniswap V3, resgatou-o através deunstakeCerts()por~5.999,25 FLOW, depositou5.000,50 FLOWatravés destakeBondsWithCode()para cunhar5.000,50 aFLOWEVMbrespaldado pela mesma quantidade deankrFLOW, e chamouunlockShares()para liberar esseankrFLOWe pagar o empréstimo e seu prêmio de0,50 ankrFLOW. Cerca de998,75 FLOWrestaram como capital de trabalho. -
Etapa 2: O atacante repetiu um swap na Uniswap V3 50 vezes. Cada ciclo trocava
ankrFLOWporWFLOWcom um limite de preço, cunhava oankrFLOWdevido ao pool dentro do callback do swap encaminhandoFLOWatravés destakeBondsWithCode()eunlockShares(), e depois desempacotava oWFLOWresultante para o próximo ciclo. Ao longo dos 50 ciclos, o pool recebeu~38.634.755,38 ankrFLOWe pagou~46.265.167,78 WFLOW, elevando o saldo do atacante de~998,75 FLOWpara~7.631.411,14 FLOW. -
Etapa 3: O atacante converteu
~38.601,95 FLOWatravés do caminho do token portador e resgatou oankrFLOWresultante através deunstakeCerts(), drenando o buffer de resgate de~46.316,57 FLOWque oFlowStakingPoolainda detinha e adicionando~7.714,62 FLOW. -
Etapa 4: O atacante voltou-se para o MORE Markets. Ele ativou a categoria de e-mode 1 ("Wrapped native tokens"), que tratava
ankrFLOWeWFLOWcomo ativosFLOWcorrelacionados e elevou o LTV doankrFLOWde 78,5% para 97%. Depositou~7.639.125,76 FLOWatravés destakeBondsWithCode(), desbloqueou oankrFLOWcorrespondente, forneceu-o como colateral, e tomou emprestado~5.668.483,10 WFLOW. Desempacotar esse empréstimo e enviá-lo de volta pelo mesmo caminho (uma única rodada de loop borrowing) adicionou uma quantidade equivalente deankrFLOW, elevando o colateral para~13.307.608,86 ankrFLOWe sustentando um segundo empréstimo de~9.819.641,05 WFLOW, totalizando uma dívida de~15.488.124,15 WFLOW, cerca de 97% do valor do colateral na taxa de oráculo de~1,19985 WFLOW.
O colateral da Etapa 4 retornava mais do que custava para cunhar: um FLOW cunhava um ankrFLOW, o oráculo avaliava esse ankrFLOW em ~1,19985 WFLOW, e o e-mode permitia emprestar 97% contra ele, de modo que os ~13,31M ankrFLOW fornecidos sustentavam ~15,49M WFLOW de dívida, ~1,16 WFLOW para cada FLOW investido. O atacante reciclou apenas uma vez porque o segundo empréstimo esgotou a reserva de WFLOW.
Esses ~15,49M WFLOW são o total bruto emprestado, e são os 15,5M WFLOW reportados como drenados da reserva do MORE Markets. Cerca de 5,67M WFLOW desse valor foi desempacotado e reciclado em colateral adicional em vez de ser mantido como retorno líquido; assim que o segundo empréstimo de ~9.819.641,05 WFLOW foi desempacotado, o atacante detinha ~9.819.641,05 FLOW como o valor sacado. Atribuído por origem do valor, ~7.630.412,39 FLOW desse valor sacado veio do pool da Uniswap V3, ~8.713,37 FLOW do FlowStakingPool, e ~2.180.515,29 FLOW do MORE Markets.
Conclusão
Uma verificação de estado que protege um ponto de entrada mas não seu equivalente é a causa raiz aqui: um caminho de cunhagem que havia sido desativado permaneceu acessível, e sua taxa não foi atualizada ao longo de dezesseis meses de atualizações semanais. Todo FLOW encaminhado por esse caminho, portanto, produzia ankrFLOW a um preço que o caminho do certificado nunca teria oferecido, e o atacante fez circular essa discrepância através de um pool da Uniswap V3, do buffer de resgate do pool de staking e de um mercado de empréstimos, convertendo ankrFLOW barato em liquidez de WFLOW a cada parada.
As proteções de pausa devem ser aplicadas em todo ponto de entrada que alcance a lógica desativada, não apenas naquele que se espera que seja chamado, e um feed de taxas que atenda a um caminho inativo deveria ser mantido atualizado ou ser configurado para reverter. Protocolos de empréstimo também devem evitar conceder um LTV elevado de e-mode a um token de liquid staking cujo preço de cunhagem é definido de forma independente de seu preço de oráculo; monitorar o custo de cunhagem, o valor de resgate e o preço do oráculo em conjunto revelaria essa divergência.
Aquifer
Em 31/08/2026, a Aquifer, um AMM proprietário market maker na Solana, foi explorada por aproximadamente $2,47M em 212 swaps bem-sucedidos, distribuídos entre USDC, USDT, HYPE, cbBTC, CASH e outros treze tokens [4]. Cada swap é liquidado como duas transferências de token, uma em cada direção, e a Aquifer permitia que o chamador escolhesse qual programa executaria cada uma delas sem jamais verificar essa escolha. O atacante nomeou um programa próprio para a transferência que deveria pagar a Aquifer, e esse programa reportou sucesso sem mover nada; a transferência na direção oposta passou pelo Token Program genuíno e entregou ativos reais dos cofres da Aquifer.
Contexto
Aquifer é um Prop AMM, o que significa que um market maker profissional fornece seu próprio estoque e mantém cotações de compra e venda, em vez de precificar swaps ao longo de uma curva de produto constante como fazem os pools no estilo Uniswap V2. A Aquifer deriva um preço a partir de sua cotação e estado de risco, e então liquida a negociação entre as Contas de Token do usuário e seus próprios cofres.
Na Solana, um Token Program é um programa executável que implementa operações como transferência, cunhagem e queima. Tokenkeg é o Token Program SPL original, e o Token-2022 é seu sucessor extensível; cada um gerencia muitos tokens diferentes em vez de um único ativo. Uma Mint Account identifica um tipo de token e armazena seu supply, decimais e autoridades, enquanto uma Token Account armazena o saldo de um detentor para um Mint específico; ambas são de propriedade do Token Program que as gerencia. Os cofres da Aquifer são Token Accounts controladas por PDAs da Aquifer.
Um trader negocia invocando a instrução swap da Aquifer e passando as contas que ela irá tocar, incluindo, para cada uma das duas transferências, o Token Program que deve executá-la. A Aquifer move esses tokens por meio de invocação entre programas (CPI): ela constrói uma instrução de transferência e a entrega ao programa nomeado por Instruction.program_id. Dados de instrução no formato de transferência só movem tokens SPL reais quando o Token Program correto os executa.
Análise da Vulnerabilidade
O programa com falha é a Aquifer (AQU1FR...Tz45). Nenhum código-fonte publicado corresponde ao seu bytecode implantado, então a análise a seguir foi recuperada a partir da desmontagem (disassembly) do programa: os nomes fn_ rotulam funções internas por seus offsets de código, e nenhum nome usado aqui, incluindo swap, vem dos desenvolvedores. O programa contém uma função de allowlist, fn_49740(), que aceita apenas Tokenkeg e Token-2022, mas nada no caminho de swap a chama. Ao lidar com um swap, o programa constrói ambas as instruções de transferência através de fn_45f20() e fn_46bf8() usando uma constante Tokenkeg fixa no código, o que necessariamente satisfaz sua verificação interna, e então sobrescreve o program_id de cada instrução com o Token Program fornecido pelo chamador antes de invocá-la:
// Reconstrução semântica do código que o programa executa para um swap; construct_transfer()
// representa fn_45f20() e fn_46bf8(), e a allowlist fn_49740() nunca é alcançada.
let checked_program = TOKENKEG_ID;
let mut output_instruction = construct_transfer(checked_program, output_accounts);
let mut input_instruction = construct_transfer(checked_program, input_accounts);
// Entradas não verificadas do chamador substituem o valor que foi verificado.
output_instruction.program_id = caller.token_program_b.key;
input_instruction.program_id = caller.token_program_a.key;
// Cada invoke() entrega a instrução de transferência a qualquer programa que o chamador tenha nomeado.
invoke(output_instruction)?;
invoke(input_instruction)?;
Aqui TOKENKEG_ID denota TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA. O valor que passa pela validação, portanto, nunca é o valor que é executado. Agravando isso, o programa não verifica o valor recebido pelo cofre após o retorno do CPI de entrada, de modo que um CPI que tem sucesso sem transferir nada é aceito como pagamento.
Análise do Ataque
O incidente é composto por 212 transações de ataque bem-sucedidas. A análise a seguir se baseia na transação 4pBV1G...T3bf, um exemplo representativo.
-
Etapa 1: O atacante invocou
swapcom uma entrada nominal de4.957,497101 USDC, para a qual a Aquifer calculou uma saída de195.849,433667 KMNO. Para a perna deKMNO, forneceu o Tokenkeg; para a perna deUSDC, forneceu seu próprio programaDMBpPM...NRgb68junto com uma conta de entrada falsa9gsKJc..., uma conta de propriedade desse programa cujos 165 bytes imitam uma Token Account SPL carregando o Mint deUSDC, a autoridade do atacante, e um saldou64::MAX. -
Etapa 2: O CPI de saída chegou ao Tokenkeg e transferiu
195.849,433667 KMNOdo cofreKMNOda Aquifer para a Token Account do atacanteEUjkGc..., que é controlada pelo signatário7fTe9p...4gRk7J.

- Etapa 3: O CPI de entrada chegou a
DMBpPM...NRgb68com dados no formato de transferência deUSDC. Esse programa retornou sucesso sem mover nenhumUSDCda origem falsa9gsKJc...para o cofre genuíno deUSDCda Aquifer7ULN1Y....

- Etapa 4: A Aquifer aceitou ambos os retornos de CPI, de modo que o swap foi confirmado atomicamente.
As mudanças de saldo resultantes para esta transação são as seguintes.
| Conta | Antes | Depois | Mudança |
|---|---|---|---|
Cofre KMNO da Aquifer 9BHsZp...FHSqG |
604.968,018277 KMNO |
409.118,584610 KMNO |
-195.849,433667 KMNO |
Conta KMNO do atacante EUjkGc... |
0 KMNO |
195.849,433667 KMNO |
+195.849,433667 KMNO |
Cofre USDC da Aquifer 7ULN1Y... |
1.620.342,341679 USDC |
1.620.342,341679 USDC |
0 USDC |
Esses números descrevem apenas esta transação de exemplo, não a perda total do incidente.
Conclusão
A causa raiz é uma entrada não validada do chamador no caminho de liquidação, não manipulação de preço ou de oráculo: o caminho de swap validava uma constante de Token Program e depois a substituía por uma fornecida pelo chamador antes de invocar a instrução, de modo que qual programa executaria a transferência que deveria pagar a Aquifer ficava a critério do chamador. Sem uma verificação de saldo pós-transferência no cofre, um programa que retorna sucesso sem mover tokens satisfaz o pagamento, enquanto a transferência na direção oposta entrega ativos reais.
Cada CPI deveria estar vinculado ao Token Program que é o dono do Mint sendo movido, resolvido a partir da Mint Account em vez de ser tomado de contas fornecidas pelo chamador, e o saldo do cofre deveria ser lido antes e depois da transferência de entrada, de modo que o swap seja revertido a menos que o cofre realmente tenha recebido o valor cotado.
Notional Finance
Em 03-04/09/2026 (UTC), o Notional Finance V1 no Ethereum foi explorado por aproximadamente $1,73M, drenado como 69.257,37 DAI e 1.658.524,86 USDC. Antes de permitir que uma conta assuma dívida, o protocolo avalia tudo que essa conta detém e deve, e uma conversão numérica insegura nesse caminho colapsou uma dívida do tamanho certo para zero. A verificação, portanto, deixou passar uma conta cujo passivo havia desaparecido, enquanto a grande reivindicação que ela havia criado permanecia intacta em outro contrato controlado pelo atacante. O atacante havia cronometrado essa reivindicação forjada para vencer na próxima maturidade do protocolo, meia-noite UTC, e a liquidou minutos depois para sacar o DAI e o USDC que o protocolo ainda detinha.
Contexto
Notional Finance V1 é um protocolo de empréstimo de taxa fixa no Ethereum. Ele representa fluxos de caixa em maturidades predefinidas com fCash: um CASH_RECEIVER é uma posição positiva com direito a receber ativos na maturidade, e um CASH_PAYER é uma posição negativa obrigada a pagá-los. As posições de fCash e outras posições de cada conta são rastreadas em seu Portfolio, onde um ativo é identificado por seu grupo de caixa (cash group) junto com sua maturidade; o grupo de caixa fixa a moeda na qual ele é liquidado. O notional de um único ativo, o valor pelo qual ele é liquidado na maturidade, é um uint128.
ERC1155Trade.safeTransferFrom() cria um par de fCash entre duas contas. A chamada tem o formato de uma transferência ERC-1155, mas nada muda de mãos: ela chama Portfolios.mintfCashPair() para criar duas posições opostas, uma positiva para o receptor e uma negativa equivalente para o pagador. Cada lado é gravado no Portfolio correspondente por _upsertAsset(), que funde uma nova posição em uma entrada existente apenas quando o grupo de caixa e a maturidade coincidem, somando os dois notionais através de uma adição verificada por SafeUInt128.
A solvência é verificada no pagador através de freeCollateral(), que combina os saldos de caixa do Escrow da conta com a avaliação de seu Portfolio, somando as entradas por moeda em um int256 com sinal. Cada saldo de moeda é então convertido em ETH pela taxa de câmbio dessa moeda, com a taxa e o escalonamento de decimais aplicados como divisões de inteiros, e o valor final do free collateral deve ser não negativo. Na maturidade, uma posição de fCash é liquidada no saldo de caixa da conta através de Escrow.portfolioSettleCash(), e um saldo de caixa positivo pode então ser sacado do Escrow como o ativo subjacente correspondente.
Análise da Vulnerabilidade
Os contratos com falha são o ponto de entrada ERC1155Trade (0xbba8...ef08), que cunha pares de fCash sem limite de notional, e o Escrow (0x9abd...f683), cuja avaliação de colateral carrega dois defeitos aritméticos em _convertToETH() que podem avaliar uma dívida como zero.

Primeiro, uma conversão de estreitamento não verificada pode truncar uma dívida grande para zero. A função converte balance.abs() diretamente para uint128 com um cast bruto, e nunca verifica se o valor cabe no tipo de destino. Os dois tipos estão longe um do outro: um int256 com sinal alcança até 2^255 - 1, enquanto o uint128 para em 2^128 - 1. Um saldo de exatamente -2^128, portanto, cabe confortavelmente dentro de int256, e balance.abs() produz 2^128 intacto. É o cast que falha: 2^128 cai um passo além do que o uint128 consegue armazenar e dá overflow para 0, de modo que a avaliação subsequente opera sobre um saldo zero e toda a dívida desaparece do cálculo de free collateral. A mesma função usa SafeCast.toUint128() para a conversão posterior do valor calculado em ETH, o que reverteria em caso de resultado fora do intervalo, enquanto a conversão anterior permanece um cast direto e trunca silenciosamente.
Segundo, a divisão de inteiros pode arredondar pequenas dívidas para zero. As divisões por er.rateDecimals e baseDecimals truncam seus restos, de modo que uma dívida suficientemente pequena também é avaliada como 0 e omitida do free collateral.
Análise do Ataque
A análise a seguir se baseia na transação 0xe1589a...25d60a.

-
Etapa 1: Às 23:58:47 UTC de 3 de setembro de 2026, o atacante chamou
safeTransferFrom()noERC1155Tradepara cunhar um par de fCash com um valor de1, usandocashGroupId = 2e o timestamp de maturidade1788480000(4 de setembro de 2026, 00:00 UTC), 73 segundos à frente e a mais próxima das duas maturidades que o protocolo tinha em aberto. Durante a cunhagem,_upsertAsset()registrou o passivo de fCash negativo no contrato do atacante e a reivindicação positiva no contrato receptor, ePortfoliosimediatamente verificou o free collateral do contrato do atacante. Esse contrato não detinha saldo em nenhuma moeda, então qualquer avaliação positiva do novo passivo teria reprovado na verificação. O segundo defeito o encobriu: na taxa de câmbio vigente, uma dívida de1não sobrevive às duas divisões de inteiros, então foi avaliada como zero e a verificação passou. -
Etapa 2: O atacante chamou
safeTransferFrom()novamente para cunhar um segundo par com um valor deuint128.max(340.282.366.920.938.463.463.374.607.431.768.211.455). Esse par reutilizoucashGroupId = 2mas carregava o timestamp de maturidade1796256000(3 de dezembro de 2026, 00:00 UTC), a outra maturidade em aberto, e enviou seu lado positivo para um contrato receptor diferente. O lado negativo caiu novamente no contrato do atacante, ao lado do resultante da Etapa 1. Uma única cunhagem nunca pode exceder2^128 - 1, sempre uma unidade a menos que o valor que causa o truncamento, então alcançá-lo exige duas posições. A maturidade diferente manteve as duas em entradas separadas, fora do alcance da adição verificada que teria revertido em caso de fusão; o grupo de caixa compartilhado ainda assim as colocou no mesmo slot de moeda na avaliação, onde a unidade da Etapa 1 elevou o total a exatamente-2^128. -
Etapa 3: Durante a verificação de colateral, o proxy do Escrow chamou
convertBalancesToETH(). O saldo negativo agregado alcançou exatamente-2^128, foi passado para_convertToETH(), e foi truncado para zero, de modo que o passivo denominado em ETH da conta foi reportado como zero. -
Etapa 4: Com a dívida removida da contabilidade de colateral, o segundo contrato receptor detinha uma grande posição positiva de fCash que o protocolo tratava como colateral disponível. Ainda dentro da mesma transação, ele cunhou mais dois pares de fCash contra esse colateral e entregou o lado positivo a mais dois contratos receptores:
69.257,37sobcashGroupId = 2, que é liquidado emDAI, e1.658.524,86sobcashGroupId = 3, que é liquidado emUSDC. Ambos os valores vieram de chamadasbalanceOfque o atacante havia feito no Escrow no início da transação, de modo que cada reivindicação foi dimensionada para um saldo que o Escrow realmente detinha. Cada uma carregava a maturidade1788480000, a 73 segundos de distância. -
Etapa 5: Às 00:01:35 UTC de 4 de setembro de 2026, 95 segundos após essa maturidade, o atacante liquidou as duas reivindicações vencidas na transação 0xc3f3e3...a24efa e sacou
~69.257,37 DAIe~1.658.524,86 USDCdo Escrow. Os fundos foram posteriormente encaminhados para0x8aaf...3be6.
Conclusão
O caminho de cunhagem não impõe limite de notional, e dois defeitos aritméticos na avaliação de colateral permitiram, cada um, que uma verificação de solvência passasse: o arredondamento permitiu que uma conta sem nada assumisse seu primeiro passivo, e uma conversão de estreitamento silenciosa então avaliou uma dívida do tamanho exatamente certo como zero, de modo que a segunda verificação passou em uma conta profundamente insolvente. A posição positiva forjada no contrato receptor foi então tratada como colateral disponível, o que permitiu ao atacante dividi-la em reivindicações correspondentes aos saldos do Escrow e sacar o DAI e o USDC que ele ainda detinha.
Saldos com sinal devem ter seu intervalo verificado antes de qualquer conversão de estreitamento, e uma conversão que não consiga representar sua entrada deveria reverter em vez de truncar. De forma mais ampla, uma verificação de solvência nunca deveria reportar uma dívida diferente de zero como zero, seja porque a aritmética a perde em um cast ou em uma etapa de arredondamento. Aplicar o cast verificado que a mesma função já usa mais adiante, ou limitar o notional que uma única cunhagem de par pode criar, teria isoladamente impedido esse exploit.
Referências
[3] https://x.com/flow_blockchain/status/2094506622429307061
[4] https://bitquery.io/investigations/aquifer-solana-hack-2-5-million
Sobre a BlockSec
A BlockSec é uma provedora completa de segurança blockchain e conformidade em criptoativos. Desenvolvemos produtos e serviços que ajudam os clientes a realizar auditoria de código (incluindo contratos inteligentes, blockchain e carteiras), interceptar ataques em tempo real, analisar incidentes, rastrear fundos ilícitos e cumprir obrigações de AML/CFT, ao longo de todo o ciclo de vida de protocolos e plataformas.
A BlockSec já publicou diversos artigos de segurança em blockchain em conferências de prestígio, reportou vários ataques zero-day em aplicações DeFi, bloqueou múltiplos ataques para resgatar mais de 20 milhões de dólares, e protegeu bilhões em criptoativos.
-
Site oficial: https://blocksec.com/
-
Conta oficial no Twitter: https://twitter.com/BlockSecTeam



