Auteur : Jiujiu
Édition : 77
Contexte
Le 4 septembre 2026, la célèbre plateforme de prêt décentralisé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 cette attaque par l’équipe de sécurité de SlowMist :
Connaissances préalables
Dans Notional Finance V1, le fCash peut être compris comme une créance en espèces avec une date d’échéance. Le cash receiver encaisse à l’échéance, tandis que le cash payer effectue le paiement à l’échéance. Le protocole enregistre les deux types de positions dans le Portfolio, puis détermine, grâce à une vérification du free collateral, si la santé d’un compte permet l’ouverture d’une position.
Le safeTransferFrom du contrat ERC1155Trade joue ici le rôle de comptabilisation des actifs. Lorsque le type d’actif transmis appartient au cas du cash receiver, le contrat appelle Portfolios.mintfCashPair ; en même temps, il enregistre une dette au payer, et enregistre au receiver une créance équivalente. Cet appel ressemble à un transfert ERC1155, mais le résultat est en réalité un mint de paires fCash.
Après l’écriture de la nouvelle position dans Portfolio, il faut calculer et vérifier le collateral libre. Le contrat RiskFramework commence par agréger les dettes fCash du payer en un int256 signé, puis convertit les soldes de chaque devise en ETH pour les additionner. Les nombres négatifs représentent la trésorerie à payer, et les nombres positifs représentent la trésorerie ou les créances dont le compte dispose. Ce résultat détermine si la transaction peut être autorisée.
Après l’échéance des positions, Portfolio appelle la fonction portfolioSettleCash de l’Escrow pour convertir le fCash arrivé à échéance en solde de trésorerie. Le compte récupère ensuite, via la fonction de retrait, les actifs DAI ou USDC correspondants depuis l’Escrow.
Cause fondamentale
Le principal mécanisme de faille de cette attaque réside dans l’implémentation par Escrow de ExchangeRate._convertToETH : il existe un morceau de code qui convertit directement l’absolu d’un entier signé en uint128.

L’attaquant crée d’abord deux positions de dette identiques pour un même payer : les montants sont respectivement 1 et uint128.max. Après addition des deux dettes, on obtient exactement 2^128. Comme, dans le calcul des risques, le paiement du payer est comptabilisé comme une valeur négative, le paramètre clé transmis à convertBalancesToETH de l’Escrow est donc -2^128.
Cependant, la version Solidity utilisée par ce contrat est 0.6.x ; ainsi, l’absolu de 2^128, une fois converti de force en uint128, déborde et devient 0. Par conséquent, cette dette n’entre pas dans le résultat d’évaluation en ETH.
Enfin, dans la fonction mintfCashPair, il suffit d’exiger que le collateral libre du payer soit supérieur ou égal à zéro. Ainsi, après que le résultat déborde vers 0, cette vérification passe : un compte qui ne dispose pas d’actifs suffisants pour couvrir la dette peut tout de même créer une position.
Analyse des étapes de l’attaque
1. Dans la transaction préalable (0xe1589a19…d60a), l’attaquant crée d’abord plusieurs contrats auxiliaires, puis appelle la fonction setApprovalForAl du contrat ERC1155Trade pour autoriser les contrats auxiliaires ; en outre, il interroge à l’avance les deux échéances de deux CashMarket, calcule les valeurs à transmettre pour les trois paramètres AssetId requis dans les opérations ultérieures.

2. Immédiatement après, l’attaquant minte via un appel à la fonction safeTransferFrom du contrat ERC1155Trade une paire fCash d’un montant égal à 1. L’actif obligataire correspondant a pour cashGroupId 2, et le timestamp de l’échéance est 1788480000 (le 4 septembre 2026 à 8h00). Cette opération appelle la fonction interne _upsertAsset pour mettre à jour, pour l’adresse from (le contrat d’attaque) et l’adresse to (le contrat de réception 1), respectivement leur dette et leur revenu attendu.

Après la fin du mint de la paire, Portfolios vérifie immédiatement le collateral libre du contrat d’attaque. Comme le montant de la première émission est trop faible, une fois converti en ETH avec le taux de change et la précision en vigueur, il est arrondi à zéro ; ainsi, la première vérification peut passer.
3. L’attaquant appelle ensuite à nouveau la fonction safeTransferFrom du contrat ERC1155Trade afin de minter une paire fCash d’un montant égal à uint128.max. Cette fois, l’actif obligataire correspondant a pour cashGroupId 2, mais le timestamp de l’échéance est 1796256000 (le 3 décembre 2026 à 8h00).
Il y a un détail : l’attaquant minte, pour deux adresses différentes, des actifs obligataires avec des échéances différentes. Cela s’explique par le fait que lors de la mise à jour de la dette pour l’adresse from, si l’actif obligataire est identique, il est directement additionné. Cela peut entraîner un revert lors du traitement de l’addition à cause du débordement (le contrat utilise une bibliothèque safeMath).

4. Après que le deuxième mint pour mettre à jour la dette et le revenu attendu des adresses from (contrat d’attaque) et to (contrat de réception 2) soit terminé, la fonction mintfCashPair appelle la fonction freeCollateral afin de calculer la position nette de collateral de l’adresse from et vérifier qu’elle est supérieure ou égale à 0, condition nécessaire pour qu’elle soit saine.

Dans le cadre de la fonction getRequirement du contrat RiskFrameork, il commence par additionner la somme des deux dettes mintees depuis l’adresse from :

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


En suivant vers la fonction _convertToETH, on constate qu’elle prend d’abord la valeur absolue de balance puis convertit une valeur de 256 bits en 128 bits via uint128. Or, la somme des dettes de from issue ci-dessus vaut -2^128 ; après prise de la valeur absolue, on obtient 2^128, et lors de la conversion forcée en uint128, cela dépasse la limite et déborde vers 0. Cela signifie que le protocole considère à tort que la position de collateral libre de l’adresse from est saine avec une valeur de 0, ce qui fait passer la vérification finale dans la fonction mintfCashPair.

5. Ensuite, le contrat de réception 2 répartit à nouveau sa position entre deux autres contrats auxiliaires. Ces deux safeTransferFrom entrent encore dans la fonction mintfCashPair. Mais à ce moment-là, le payer est le contrat de réception 2 lors du deuxième mint, qui détient la très grande position de revenu attendu obtenue à l’étape précédente ; dans le calcul des risques, cela est interprété comme une créance positive, ce qui fait passer la vérification du collateral. Les deux contrats auxiliaires obtiennent ainsi, une fois arrivés à échéance, des receiver fCash qui peuvent être convertis en trésorerie.

6. L’attaquant initie ensuite la deuxième transaction officielle visant à réaliser un profit. Dans cette transaction, les contrats auxiliaires correspondant aux deux positions détenues de la transaction précédente règlent respectivement leurs créances, augmentant ainsi le solde de trésorerie de l’actif correspondant. Ensuite, l’attaquant appelle la fonction withdraw du contrat Escrow pour retirer ses actifs et réaliser le profit.

résumer
Le point clé de cette attaque est que l’attaquant utilise d’abord deux positions distinctes pour faire en sorte que la somme de la dette du payer atteigne 2^128, puis fait convertir cette dette en ETH via l’Escrow. En exploitant un problème de débordement dans la conversion de type, le résultat est tronqué à zéro ; les contrôles de risque sont alors validés.
L’équipe de sécurité SlowMist recommande au projet d’effectuer des contrôles de plage avant toute conversion de type : tout résultat dépassant type(uint128).max doit être refusé directement. En parallèle, toutes les limites impliquant des montants signés, le redimensionnement de précision et des montants d’actifs doivent être ajoutées à des tests aux valeurs extrêmes, en particulier pour couvrir des entrées comme uint128.max, uint128.max + 1 et des valeurs correspondant à l’absolu d’un négatif. Il est possible de se référer à, ou d’utiliser, la bibliothèque SafeCast d’Openzeppelin pour gérer les conversions.

