Autor: 99
Editado por: 77
背景
Em 4 de setembro de 2026, a renomada plataforma descentralizada de empréstimos Notional Finance foi atacada, resultando em uma perda de cerca de 1,73 milhão de dólares. A seguir está a análise específica da equipe de segurança da SlowMist sobre este incidente:
Conhecimentos preliminares
No Notional Finance V1, o fCash pode ser entendido como um crédito em dinheiro com data de vencimento. O cash receiver recebe o pagamento no vencimento, enquanto o cash payer realiza o pagamento no vencimento. O protocolo registra essas duas posições no Portfolio e, em seguida, determina por meio da verificação de colateral livre se a conta está em condições saudáveis para abrir uma posição.
A função safeTransferFrom do ERC1155Trade aqui assume a função de contabilização de ativos. Quando o tipo de ativo passado é cash receiver, o contrato chama Portfolios.mintfCashPair, registrando uma dívida para o payer e um crédito equivalente para o receiver. Essa chamada parece uma transferência ERC1155, mas na verdade é uma cunhagem em par de fCash.
Após a escrita da nova posição no Portfolio, é necessário calcular e verificar o colateral livre. O contrato RiskFramework primeiro agrega as obrigações de fCash do payer em um int256 com sinal e depois converte os saldos de cada moeda para ETH para somá-los. Valores negativos representam dinheiro que precisa ser pago, enquanto valores positivos representam que a conta possui caixa ou crédito. O resultado desse cálculo determina se a transação pode ser permitida.
Após o vencimento da posição, o Portfolio chamará a função portfolioSettleCash do Escrow para transformar o fCash vencido em saldo em dinheiro. A conta então retira do Escrow os ativos correspondentes de DAI ou USDC por meio da função de saque.
Causa raiz
A vulnerabilidade central deste ataque está em ExchangeRate._convertToETH, na implementação do contrato Escrow, onde há um trecho de código que converte diretamente o valor absoluto de um inteiro com sinal para uint128.

O atacante primeiro estabeleceu duas posições de dívida com o mesmo payer, com valores de 1 e uint128.max, respectivamente. Depois de somadas, as duas dívidas resultam exatamente em 2^128. Como, na computação de risco, o pagamento do payer é registrado como número negativo, o parâmetro-chave passado para o convertBalancesToETH do Escrow é -2^128.
No entanto, a versão Solidity usada por este contrato é a 0.6.x, portanto o valor absoluto 2^128, após ser forçadamente convertido para uint128, transborda e vira 0, e essa obrigação então não entra no resultado precificado em ETH.
Por fim, a função mintfCashPair exige apenas que o colateral livre do payer seja maior ou igual a zero. Assim, depois que o resultado transborda para 0, essa verificação pode ser aprovada; uma conta sem ativos suficientes para cobrir suas dívidas ainda pode abrir posição.
Análise dos passos do ataque
1. Na transação prévia (0xe1589a19…d60a), o atacante primeiro criou vários contratos auxiliares e chamou a função setApprovalForAl do contrato ERC1155Trade para autorizar os contratos auxiliares; além disso, também consultou antecipadamente as datas de vencimento de dois CashMarkets e calculou os valores dos três parâmetros AssetId que precisariam ser passados nas operações subsequentes.

2. Em seguida, o atacante chamou a função safeTransferFrom do contrato ERC1155Trade para cunhar um par de fCash no valor de 1, correspondente a um ativo de título com cashGroupId 2 e timestamp de vencimento 1788480000 (4 de setembro de 2026, às 8:00). Nessa etapa, a função interna _upsertAsset é chamada para atualizar as obrigações e a receita esperada do endereço from (contrato atacante) e do endereço to (contrato de recebimento 1), respectivamente.

Após a cunhagem do par ser concluída, o Portfolios verifica imediatamente o colateral livre do contrato atacante; como a quantidade cunhada na primeira vez era muito pequena, ao ser convertida para ETH na taxa de câmbio e precisão atuais ela é arredondada para zero, então a primeira verificação pode ser aprovada.
3. O atacante então chamou novamente a função safeTransferFrom do contrato ERC1155Trade para cunhar um par de fCash no valor de uint128.max; desta vez, o cashGroupId do ativo de título correspondente era 2, mas o timestamp de vencimento era 1796256000 (3 de dezembro de 2026, às 8:00).
Há um detalhe aqui: o atacante cunhou ativos de título com datas de vencimento diferentes para dois endereços distintos. Isso ocorre porque, ao atualizar a obrigação do endereço from, se o ativo de título for o mesmo, ele será simplesmente acumulado, o que causaria overflow durante a operação de adição e faria revert (o contrato usa a biblioteca safeMath).

4. Depois que a segunda cunhagem atualiza as obrigações e a receita esperada do endereço from (contrato atacante) e do endereço to (contrato de recebimento 2), a função mintfCashPair chama a função freeCollateral para calcular a posição líquida de colateral do endereço from e verifica se ela precisa ser maior ou igual a 0 para ser considerada saudável.

Primeiro, na função getRequirement do contrato RiskFrameork, a soma das duas cunhagens de dívida do endereço from é calculada da seguinte forma:

Seu resultado final é -2^128; em seguida, a função convertBalancesToETH do contrato Escrow é chamada para converter esse valor para precificação em ETH, e a função convertBalancesToETH por sua vez chama a função _convertToETH da biblioteca ExchangeRate para realizar o cálculo:


Ao seguir para a função _convertToETH, pode-se ver que ela primeiro toma o valor absoluto de balance e depois converte de 256 bits para 128 bits usando uint128. Porém, a soma total das obrigações de from acima era -2^128; o valor absoluto 2^128, ao ser convertido forçadamente para uint128(), ultrapassa o limite e transborda para 0. Isso significa que o protocolo julgou erroneamente que a posição de colateral livre do endereço from era 0 e saudável, passando assim na verificação final da função mintfCashPair.

5. Depois disso, o contrato de recebimento 2 também dividiu sua posição para outros dois contratos auxiliares; essas duas chamadas safeTransferFrom ainda entram na função mintfCashPair. Porém, como nesse momento o payer é o contrato de recebimento 2 da segunda cunhagem, que detém a enorme posição de receita esperada obtida na etapa anterior, a computação de risco o considera um crédito positivo, passando assim na verificação de colateral, e os dois contratos auxiliares obtêm, portanto, receiver fCash que poderá ser convertido em dinheiro após o vencimento.

6. Em seguida, o atacante iniciou uma segunda transação formal de obtenção de lucro. Nessa transação, foram liquidados, para os contratos auxiliares das duas posições mantidas da transação anterior, os créditos correspondentes, aumentando os saldos em caixa dos ativos correspondentes; depois, a função withdraw do contrato Escrow foi chamada para sacar os ativos e realizar o lucro.

Resumo
O ponto-chave deste ataque é que o atacante primeiro usou duas posições separadas para somar a dívida de payer até 2^128 e então fez com que o Escrow convertesse essa dívida em ETH. Ao truncar o resultado para zero por meio de uma vulnerabilidade de overflow na conversão de tipos, a checagem de risco foi então aprovada.
A equipe de segurança SlowMist recomenda que o projeto faça verificação de faixa obrigatória antes da conversão de tipos; resultados que excedam type(uint128).max devem ser rejeitados diretamente. Ao mesmo tempo, todos os limites envolvendo valores com sinal, escalonamento de precisão e denominações de ativos devem incluir testes de valores extremos, especialmente cobrindo entradas como uint128.max, uint128.max + 1 e valores absolutos de números negativos. É possível consultar ou usar a biblioteca SafeCast da OpenZeppelin para tratar as conversões.

