Auteur : Jiujiu
Édité : 77
Context
Le 4 septembre 2026, la plateforme de prêt décentralisée réputée Notional Finance a été attaquée, entraînant une perte d'environ 1,73 million de dollars. Voici l'analyse détaillée de l'équipe de sécurité Slow Mist concernant cet incident :
Connaissances préalables
Dans Notional Finance V1, fCash peut être compris comme une créance en espèces avec une date d'échéance. Le receveur d'espèces reçoit le paiement à l'échéance, tandis que le payeur d'espèces effectue le paiement à l'échéance. Le protocole enregistre les deux positions dans le Portfolio et évalue la santé d'un compte pour déterminer s'il peut ouvrir une position via une vérification des actifs de garantie libres.
La fonction safeTransferFrom d'ERC1155Trade assure ici la fonction de tenue des comptes d'actifs. Lorsque le type d'actif transmis appartient au cash receiver, le contrat appelle Portfolios.mintfCashPair, enregistrant une dette pour le payer et un créance équivalente pour le receiver. Ce appel ressemble à un transfert ERC1155, mais il résulte en une création par paires d'fCash.
Après l'écriture d'une nouvelle position dans le Portfolio, un calcul et une vérification des actifs libres sont nécessaires. Le contrat RiskFramework regroupe d'abord les passifs fCash du payeur en un int256 signé, puis convertit les soldes de chaque devise en ETH pour les additionner. Un nombre négatif indique une somme à payer, tandis qu'un nombre positif représente la trésorerie ou les créances détenues par le compte. Ce résultat détermine si la transaction peut être autorisée.
Après l'échéance de la position, Portfolio appelle la fonction portfolioSettleCash d'Escrow pour convertir les fCash échus en solde en espèces. Le compte retire ensuite les actifs DAI ou USDC correspondants via la fonction de retrait d'Escrow.
Cause fondamentale
La vulnérabilité centrale de cette attaque réside dans la fonction ExchangeRate._convertToETH utilisée dans le contrat Escrow, où un code convertit directement la valeur absolue d'un entier signé en uint128.

L'attaquant a d'abord établi deux positions de dette avec le même payer, d'un montant respectif de 1 et de uint128.max. La somme de ces deux dettes équivaut exactement à 2^128. Étant donné que les paiements du payer sont enregistrés comme des nombres négatifs dans le calcul du risque, le paramètre clé transmis à convertBalancesToETH de l'Escrow est donc -2^128.
Cependant, la version de Solidity utilisée pour ce contrat est la 0.6.x, donc sa valeur absolue 2^128, lorsqu'elle est convertie en uint128, déborde et devient 0, ce qui fait que cette dette n'est pas prise en compte dans le résultat évalué en ETH.
Dans la fonction mintfCashPair, seul le collateral libre du payer doit être supérieur ou égal à zéro, ce qui permet de passer cette vérification après un débordement résultant en zéro, permettant ainsi à un compte ne disposant pas d'actifs suffisants pour couvrir ses passifs de créer une position.
Analyse des étapes d'attaque
1. Dans la transaction précédente (0xe1589a19…d60a), l'attaquant a d'abord créé plusieurs contrats auxiliaires et appelé la fonction setApprovalForAll du contrat ERC1155Trade pour autoriser les contrats auxiliaires ; en outre, il a consulté à l'avance les dates d'échéance de deux CashMarket et calculé les valeurs des trois paramètres AssetId nécessaires pour les opérations suivantes.

2. Ensuite, l'attaquant a créé un fCash pair d'un montant de 1 en appelant la fonction safeTransferFrom du contrat ERC1155Trade, avec un cashGroupId de 2 pour l'actif obligataire et un horodatage d'échéance de 1788480000 (4 septembre 2026 à 8h). Cette opération appelle la fonction interne _upsertAsset pour mettre à jour les passifs et les revenus attendus pour l'adresse from (le contrat attaquant) et l'adresse to (le contrat récepteur 1).

Après la création de la paire, les portefeuilles vérifient immédiatement les actifs de garantie libres du contrat d'attaque ; comme la quantité créée lors de la première création est trop faible, elle est arrondie à zéro lors de la conversion en ETH selon le taux de change et la précision actuels, ce qui permet de passer la première vérification.
3. L'attaquant a ensuite appelé à nouveau la fonction safeTransferFrom du contrat ERC1155Trade pour créer une paire fCash d'un montant de uint128.max, où le cashGroupId correspondant aux actifs obligataires est 2, mais le timestamp d'échéance est 1796256000 (3 décembre 2026 à 8 heures).
Il y a un détail : l'attaquant a émis des actifs d'obligations avec des dates d'échéance différentes sur deux adresses distinctes. Cela s'explique par le fait que, lors de la mise à jour des passifs pour l'adresse « from », si les actifs d'obligations étaient identiques, ils seraient directement additionnés, ce qui provoquerait un débordement lors du calcul d'addition et entraînerait un revert (le contrat utilise la bibliothèque SafeMath).

4. Après la mise à jour des passifs et des revenus attendus pour l'adresse from (contrat d'attaque) et l'adresse to (contrat de réception 2), la fonction mintfCashPair appelle la fonction freeCollateral pour calculer la position nette de garantie de l'adresse from et vérifier qu'elle est supérieure ou égale à zéro pour être saine.

Il additionnera d'abord la somme des dettes des deux émissions de l'adresse from dans la fonction getRequirement du contrat RiskFramework :

Le résultat final est -2^128, puis le contrat Escrow appelle la fonction convertBalancesToETH pour convertir cette valeur en ETH, et la fonction convertBalancesToETH appelle à son tour la fonction _convertToETH de la bibliothèque ExchangeRate pour effectuer le calcul :


En suivant jusqu'à la fonction _convertToETH, on constate qu'elle prend d'abord la valeur absolue de balance, puis utilise uint128 pour convertir une valeur de 256 bits en 128 bits. La somme des dettes de l'adresse from est de -2^128 ; après prise de la valeur absolue, 2^128, lors de la conversion forcée avec uint128(), dépasse la limite supérieure et déborde pour devenir 0. Cela signifie que le protocole considère à tort que la position de collatéral libre de l'adresse from est de 0, ce qui est jugé sain, permettant ainsi de passer le contrôle final dans la fonction mintfCashPair.

5. Ensuite, le contrat 2 a réparti sa position entre deux contrats auxiliaires supplémentaires, et ces deux safeTransferFrom continueront d'appeler la fonction mintfCashPair. Toutefois, comme le payeur est désormais le contrat 2, qui a reçu lors de la deuxième émission un grand volume de position de revenu attendu, le calcul de risque le considère comme une créance positive, ce qui permet de passer l'inspection de garantie. Ainsi, les deux contrats auxiliaires obtiennent des fCash de réception qui pourront être échangés contre des liquidités à l'échéance.

6. L'attaquant a ensuite lancé une deuxième transaction de profit officielle, dans laquelle les créances correspondantes des deux positions détenues dans la transaction précédente ont été réglées via les contrats auxiliaires, augmentant ainsi les soldes en espèces des actifs correspondants, avant d'appeler la fonction withdraw du contrat Escrow pour retirer ses actifs et réaliser un profit.
Résumé
Le point clé de cette attaque réside dans le fait que l'attaquant a d'abord utilisé deux positions séparées pour cumuler les dettes du payer à 2^128, puis a fait en sorte que l'Escrow convertisse cette dette en ETH. En exploitant un dépassement de capacité lors de la conversion de type, le résultat a été tronqué à zéro, ce qui a permis à l'analyse de risque de l'accepter.
L'équipe de sécurité SlowMist recommande aux équipes de projets d'effectuer une vérification de plage avant toute conversion de type, en rejetant directement les résultats dépassant type(uint128).max. Par ailleurs, toutes les limites concernant les montants signés, l'échelle de précision et la valeur nominale des actifs doivent faire l'objet de tests aux extrêmes, en particulier pour les entrées suivantes : uint128.max, uint128.max + 1 et les valeurs absolues négatives. Il est possible de consulter ou d'utiliser la bibliothèque SafeCast d'OpenZeppelin pour effectuer les conversions.
