Autor: Jiujiu
Editado: 77
Fondo
El 4 de septiembre de 2026, la conocida plataforma de préstamos descentralizados Notional Finance sufrió un ataque y perdió aproximadamente 1,73 millones de dólares. A continuación se presenta el análisis detallado del equipo de seguridad de SlowMist sobre este incidente:
Conocimientos previos
En Notional Finance V1, fCash puede entenderse como un derecho de crédito en efectivo con fecha de vencimiento. El receptor del efectivo cobra al vencimiento, mientras que el pagador del efectivo paga al vencimiento. El protocolo registra ambas posiciones en el Portfolio y luego determina, mediante la verificación de colateral libre, si la salud de una cuenta permite abrir posiciones.
La función safeTransferFrom de ERC1155Trade aquí asume el papel de contabilización de activos. Cuando el tipo de activo entrante corresponde a cash receiver, el contrato llamará a Portfolios.mintfCashPair, registrando al mismo tiempo un pasivo para el payer y un crédito equivalente para el receiver. Esta llamada parece una transferencia ERC1155, pero en realidad es una acuñación por pares de fCash.
Después de escribir la nueva posición en Portfolio, es necesario calcular y verificar el colateral libre. El contrato RiskFramework primero agrega los pasivos de fCash del payer en un int256 con signo y luego convierte los saldos de cada moneda a ETH para sumarlos. Un valor negativo representa efectivo que debe pagarse, mientras que un valor positivo representa efectivo o crédito que posee la cuenta. El resultado de este cálculo determina si se permite o no la transacción.
Después de que la posición vence, Portfolio llama a la función portfolioSettleCash de Escrow para convertir el fCash vencido en saldo en efectivo. Luego, la cuenta retira el DAI o USDC correspondiente desde Escrow mediante la función de retiro.
Causa raíz
La vulnerabilidad principal de este ataque reside en ExchangeRate._convertToETH, en la implementación del contrato Escrow, donde hay un fragmento de código que convierte directamente el valor absoluto de un entero con signo a uint128.

El atacante primero estableció dos posiciones de pasivo con el mismo payer, por montos de 1 y uint128.max. Al sumarlas, el total resulta justamente 2^128. Dado que en el cálculo de riesgo el pago del payer se registra como un número negativo, el parámetro clave que recibe convertBalancesToETH de Escrow es -2^128.
Sin embargo, la versión de Solidity utilizada por este contrato es 0.6.x, por lo que su valor absoluto 2^128, al ser convertido forzosamente a uint128, se desbordará y se convertirá en 0; así, este pasivo no entrará en el resultado valorado en ETH.
Finalmente, en la función mintfCashPair solo se exige que el colateral libre del payer sea mayor o igual que cero; por tanto, una vez que el resultado se desborda y se convierte en 0, esa verificación puede superarse, y una cuenta sin activos suficientes para cubrir sus pasivos aún puede abrir una posición.
Análisis de los pasos del ataque
1. En la transacción preliminar (0xe1589a19…d60a), el atacante primero creó varios contratos auxiliares y llamó a la función setApprovalForAll del contrato ERC1155Trade para autorizar a esos contratos auxiliares; además, consultó con antelación las fechas de vencimiento de dos CashMarket y calculó los valores de los tres parámetros AssetId que debían pasarse en las operaciones posteriores.

2. Inmediatamente después, el atacante acuñó un par de fCash por un monto de 1 llamando a la función safeTransferFrom del contrato ERC1155Trade, correspondiente a un activo de bono con cashGroupId 2 y una marca de tiempo de vencimiento de 1788480000 (4 de septiembre de 2026 a las 8:00). En este proceso se llama a la función interna _upsertAsset para actualizar los pasivos y los ingresos esperados de la dirección from (el contrato atacante) y de la dirección to (el contrato receptor 1), respectivamente.

Después de completar la acuñación del par, Portfolios verificará de inmediato el colateral libre del contrato atacante, y como la primera acuñación fue de una cantidad demasiado pequeña, al convertirla a ETH con el tipo de cambio y la precisión actuales se redondeará a cero, por lo que la primera verificación puede superarse.
3. El atacante luego volvió a llamar a la función safeTransferFrom del contrato ERC1155Trade para acuñar un par de fCash por un monto de uint128.max; esta vez, el cashGroupId del activo de bono correspondiente era 2, pero la marca de tiempo de vencimiento era 1796256000 (3 de diciembre de 2026 a las 8:00).
Aquí hay un detalle: el atacante acuñó activos de bono con diferentes fechas de vencimiento para dos direcciones distintas. Esto se debe a que, al actualizar los pasivos de la dirección from, si el activo de bono es el mismo, se acumulará directamente, lo que puede provocar que, al procesar la suma, ocurra un desbordamiento y se produzca un revert (el contrato usa la biblioteca safeMath).

4. Después de completar la actualización de pasivos e ingresos esperados de la dirección from (el contrato atacante) y de la dirección to (el contrato receptor 2) durante la segunda acuñación, la función mintfCashPair llamará a freeCollateral para calcular la posición neta de colateral de la dirección from y comprobar que debe ser mayor o igual a 0 para considerarse sana.

Primero, en la función getRequirement del contrato RiskFrameork, se suman los pasivos acumulados por las dos acuñaciones de la dirección from:

Su resultado final es -2^128, y luego se llama a la función convertBalancesToETH del contrato Escrow para convertir este valor a valoración en ETH; a su vez, la función convertBalancesToETH llamará a la función _convertToETH de la biblioteca ExchangeRate para realizar el cálculo:


Al seguir la función _convertToETH, se puede ver que primero toma el valor absoluto de balance y luego convierte de 256 bits a 128 bits con uint128. Como la suma total de pasivos de la dirección from era -2^128, el valor absoluto 2^128 al realizar la conversión forzada uint128() desbordará y se truncará a 0. Esto significa que el protocolo creyó erróneamente que la posición de colateral libre de la dirección from era 0 y estaba sana, por lo que pasó la verificación final dentro de la función mintfCashPair.

5. Después, el contrato receptor 2 volvió a dividir sus posiciones entre otros dos contratos auxiliares, y estos dos safeTransferFrom aún entraron en la función mintfCashPair. Sin embargo, como en este punto el payer es el contrato receptor 2 de la segunda acuñación, y posee la enorme posición de ingresos esperados obtenida en el paso anterior, el cálculo de riesgo lo considera un crédito positivo, por lo que pasa la verificación de colateral; así, los dos contratos auxiliares obtienen fCash de receiver que puede convertirse en efectivo al vencimiento.

6. Posteriormente, el atacante inició una segunda transacción formal de beneficio; en esta transacción, liquidó los créditos correspondientes para los contratos auxiliares de las dos posiciones mantenidas en la transacción anterior, incrementó el saldo en efectivo de los activos correspondientes y, después, llamó a la función withdraw del contrato Escrow para retirar los activos y obtener beneficio.

Resumen
El punto clave de este incidente es que el atacante primero utilizó dos posiciones separadas para acumular la suma de los pasivos del payer hasta 2^128 y luego hizo que Escrow convirtiera ese pasivo a ETH. Aprovechando el desbordamiento en la conversión de tipos para truncar el resultado a cero, la verificación de riesgo lo permitió.
El equipo de seguridad SlowMist recomienda al equipo del proyecto realizar una comprobación de rango antes de cualquier conversión de tipos; si el resultado supera type(uint128).max, debe rechazarse directamente. Al mismo tiempo, todos los límites relacionados con montos con signo, escalado de precisión y denominaciones de activos deben incluir pruebas de valores extremos, especialmente cubriendo entradas como uint128.max, uint128.max + 1 y el valor absoluto de números negativos. Se puede consultar o utilizar la biblioteca SafeCast de OpenZeppelin para el tratamiento de conversiones.

