Autor: Jiu Jiu
Editado: 77
Fondo
El 4 de septiembre de 2026, la conocida plataforma de préstamos descentralizados Notional Finance sufrió un ataque, con pérdidas de aproximadamente 1,73 millones de dólares. A continuación, el equipo de seguridad de Slow Mist presenta un análisis detallado del incidente:
Conocimientos previos
En Notional Finance V1, fCash puede entenderse como un crédito en efectivo con una fecha de vencimiento. El receptor de efectivo recibe el pago al vencimiento, y el pagador de efectivo realiza el pago al vencimiento. El protocolo registra ambas posiciones en el Portfolio y evalúa la solvencia de una cuenta para abrir una posición mediante la verificación de activos colaterales libres.
El safeTransferFrom de ERC1155Trade realiza aquí la función de contabilidad de activos. Cuando el tipo de activo transmitido pertenece al cash receiver, el contrato llama a Portfolios.mintfCashPair, registrando al mismo tiempo una obligación para el payer y un crédito equivalente para el receiver. Esta llamada parece una transferencia ERC1155, pero en realidad resulta en la creación emparejada de fCash.
Después de que se escriba una nueva posición en el Portfolio, se debe realizar el cálculo y la verificación de los activos libres. El contrato RiskFramework primero suma las pasivas fCash del pagador como un int256 con signo, luego convierte los saldos de cada criptomoneda a ETH para sumarlos. Los números negativos representan efectivo que se debe pagar, y los positivos representan efectivo o créditos que posee la cuenta. Este resultado determina si se permite o no la transacción.
Después de que expire la posición, Portfolio llamará a la función portfolioSettleCash de Escrow para convertir el fCash vencido en saldo en efectivo. Luego, la cuenta retirará los activos DAI o USDC correspondientes mediante la función de retiro de Escrow.
Causa raíz
La vulnerabilidad central de este ataque radica en ExchangeRate._convertToETH, utilizado en el contrato de implementación de Escrow, donde hay un fragmento de código que convierte directamente el valor absoluto de un entero con signo en uint128.

El atacante primero creó dos posiciones de deuda con el mismo pagador, con montos de 1 y uint128.max. La suma de ambas deudas resulta exactamente en 2^128. Dado que los pagos del pagador se registran como números negativos en el cálculo de riesgo, el parámetro clave pasado a convertBalancesToETH del Escrow es -2^128.
Sin embargo, la versión de Solidity utilizada en este contrato es 0.6.x, por lo que su valor absoluto 2^128, al ser convertido forzosamente a uint128, se desborda y se convierte en 0, por lo que esta pasividad no entra en el resultado denominado en ETH.
Finalmente, en la función mintfCashPair, solo se requiere que el activo libre del pagador sea mayor o igual a cero, por lo que, tras un desbordamiento que resulta en 0, se puede superar esta verificación, permitiendo que una cuenta sin activos suficientes para cubrir sus pasivos aún pueda abrir una posición.
Análisis de los pasos del ataque
1. En la transacción previa (0xe1589a19…d60a), el atacante primero creó múltiples contratos auxiliares y llamó a la función setApprovalForAll del contrato ERC1155Trade para autorizar los contratos auxiliares; además, consultó previamente las fechas de vencimiento de dos CashMarket y calculó los valores de los tres parámetros AssetId necesarios para las operaciones posteriores.

2. A continuación, el atacante mintió un par fCash con una cantidad de 1 llamando a la función safeTransferFrom del contrato ERC1155Trade, con el cashGroupId correspondiente al activo de bono igual a 2 y la marca de tiempo de vencimiento en 1788480000 (4 de septiembre de 2026, 8:00). Esto invoca la función interna _upsertAsset para actualizar los pasivos y los ingresos esperados de la dirección from (contrato atacante) y la dirección to (contrato receptor 1).

Una vez completada la acuñación del par, Portfolios revisa inmediatamente los activos de garantía libre del contrato de ataque; debido a que la cantidad de la primera acuñación es demasiado pequeña, al convertirla a ETH con el tipo de cambio y la precisión actuales, se redondea a cero, por lo que la primera revisión se aprueba.
3. El atacante llamó nuevamente a la función safeTransferFrom del contrato ERC1155Trade para emitir un par fCash con una cantidad de uint128.max, donde el cashGroupId del activo de bono correspondiente es 2, pero la marca de tiempo de vencimiento es 1796256000 (3 de diciembre de 2026, 8:00).
Hay un detalle: el atacante acuñó activos de bonos con fechas de vencimiento diferentes en dos direcciones distintas. Esto se debe a que, al actualizar la deuda en la dirección "from", si los activos de bonos fueran iguales, se sumarían directamente, lo que provocaría un desbordamiento durante la operación de suma y, por lo tanto, un revert (el contrato utiliza la biblioteca SafeMath).

4. Después de actualizar los pasivos y los ingresos esperados para la dirección "from" (contrato de ataque) y la dirección "to" (contrato receptor 2), la función mintfCashPair llama a la función freeCollateral para calcular la posición neta de colateral de la dirección "from" y verificar que sea mayor o igual a cero para considerarla saludable.

Primero se sumarán los pasivos totales de las dos acuñaciones de la dirección from en la función getRequirement del contrato RiskFramework:

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


Al rastrear hasta la función _convertToETH, se puede observar que primero toma el valor absoluto de balance y luego utiliza uint128 para convertir de 256 bits a 128 bits. La suma total de la deuda en el origen es -2^128; al tomar su valor absoluto, 2^128 al realizarse la conversión forzada con uint128() excede el límite superior y se desborda a 0. Esto significa que el protocolo interpreta erróneamente que la posición de colateral libre del origen es 0, lo cual se considera saludable, permitiendo así pasar la verificación final en la función mintfCashPair.

5. Luego, el contrato 2 recibido dividió su posición entre otros dos contratos auxiliares; estos dos safeTransferFrom aún ingresarán a la función mintfCashPair. Sin embargo, como en este momento el pagador es el contrato 2 recibido en la segunda emisión, que posee el enorme saldo de ingresos esperados obtenido en el paso anterior, el cálculo de riesgo lo considera como un activo positivo, lo que permite superar la verificación de garantía; por lo tanto, los dos contratos auxiliares obtienen fCash receptor que podrá convertirse en efectivo al vencimiento.

6. Luego, el atacante inició una segunda transacción de beneficio oficial, en la que se liquidaron los contratos auxiliares correspondientes a las dos posiciones mantenidas en la transacción anterior, aumentando los saldos en efectivo de los activos correspondientes, y posteriormente llamó a la función withdraw del contrato Escrow para retirar sus activos y obtener beneficios.
Resumen
El punto clave del ataque fue que el atacante primero utilizó dos posiciones separadas para sumar la deuda del pagador hasta 2^128, y luego hizo que Escrow convirtiera esta deuda en ETH. Aprovechando una vulnerabilidad de desbordamiento en la conversión de tipos, el resultado se truncó a cero, lo que permitió que las comprobaciones de riesgo pasaran sin detectar el problema.
El equipo de seguridad de SlowMist recomienda que los proyectos realicen una verificación de rango antes de realizar conversiones de tipo; rechacen directamente los resultados que excedan type(uint128).max. Al mismo tiempo, todos los límites relacionados con cantidades con signo, escalado de precisión y denominación de activos deben incluir pruebas de valores extremos, especialmente cubriendo entradas como uint128.max, uint128.max + 1 y valores absolutos negativos. Se puede consultar o utilizar la biblioteca SafeCast de OpenZeppelin para realizar las conversiones.
