Abus d'arrondi (CWE-682) : erreurs virgule flottante, zéro signé et taux obsolètes pour extraire des micro-crédits — salami slicing sur les API fintech.
TL;DR
mf-calculation, form_id=0) ou réutilisent un PaymentIntent confirmé à 0,01 $ pour exécuter une commande de 299 $.mulDown/mulUp à travers des batch swaps.payment_intent.amount_received contre le montant attendu avant l'exécution.L'abus d'arrondi monétaire est une vulnérabilité de logique métier où des attaquants extraient de la valeur en exploitant la façon dont une application convertit, additionne ou arrondit des montants monétaires. Contrairement à la manipulation de prix classique — où l'attaquant altère brutalement price=0.01 — l'abus d'arrondi est structurel : les mathématiques elles-mêmes, ou le modèle de confiance autour des mathématiques, fuient de la valeur à chaque transaction. Le montant par événement est négligeable. Le cumul, à débit d'API, est matériel.
La classe couvre trois CWE et deux entrées OWASP. CWE-682 (Incorrect Calculation) capture l'erreur arithmétique pure : une taxe sur 4,997 $ à 8 % donne 0,39976 $, mais une colonne DECIMAL(10,2) arrondit à 0,40 $ — ces 0,00024 $ perdus doivent vivre quelque part. CWE-1339 (Insufficient Precision or Accuracy of a Real Number) couvre la cause racine IEEE 754. OWASP A04:2021 — Insecure Design (renommée A06:2025 — Insecure Design dans la mise à jour OWASP Top 10:2025) s'applique aux bibliothèques mathématiques héritées basées sur des float toujours en production. Et OWASP BLA3:2025 (Object State Manipulations) — ajouté au Top 10 Business Logic Abuse 2025 — nomme directement la variante moderne : champs numériques acceptés du client sans recalcul côté serveur. La vague de CVE 2026 (MetForm Pro, SureForms, Formidable Forms) n'est pas un bug de float. C'est BLA3 en production.
La manipulation de prix se corrige en validant un seul champ contre un catalogue. L'abus d'arrondi exige de choisir le bon type numérique, le bon mode d'arrondi et la bonne chaîne de vérification sur l'ensemble du cycle de vie du paiement. Les deux peuvent être présents dans le même endpoint, mais ils échouent différemment et requièrent des défenses différentes.
IEEE 754 — la norme de 1985 implémentée par chaque CPU moderne et chaque float / double / JavaScript Number — représente les fractions en binaire. Le décimal 0.1 n'a pas de représentation binaire exacte. Le double 64 bits le plus proche est 0.1000000000000000055511151231257827021181583404541015625. La composition de cela à travers une chaîne d'addition produit la démonstration canonique :
>>> 0.1 + 0.1 + 0.1
0.30000000000000004 # not 0.3
>>> 0.1 * 3 == 0.3
False
>>> from decimal import Decimal
>>> Decimal("0.1") + Decimal("0.1") + Decimal("0.1") == Decimal("0.3")
TrueDans une boucle de panier, un agrégateur de taxe ou un moteur de change, ces fractions fantômes s'accumulent de façon prévisible. Un attaquant qui rétro-ingénierie l'implémentation peut concevoir des entrées qui arrondissent systématiquement en sa faveur.
Tout chemin de code monétaire contenant float, double ou JavaScript Number est présumé vulnérable jusqu'à preuve du contraire. La monnaie doit être stockée en centimes entiers (ou millicentimes pour le FX) et l'arithmétique maintenue en espace entier. DECIMAL(10,2) est acceptable pour le stockage mais perd la précision sub-centime dans les calculs intermédiaires.
La seconde moitié du mécanisme est la direction d'arrondi. Deux conventions dominent :
0,005 $ arrondit à 0,01 $, 0,004 $ arrondit à 0,00 $. Prévisible mais biaisé vers le haut.0,005 $ arrondit à 0,00 $ (centime pair le plus proche), 0,015 $ arrondit à 0,02 $. Élimine le biais vers le haut lors de la somme de millions de valeurs.Le banker's rounding est mathématiquement supérieur pour l'agrégation non biaisée, mais la plupart des flux orientés consommateur supposent du half-up. Mélanger les deux dans une même transaction — par exemple, des lignes arrondies en half-up, le total du panier en banker's — produit l'asymétrie que les attaquants exploitent. Choisissez un mode par domaine, documentez-le et appliquez-le via un utilitaire partagé.
L'exploit Balancer V2 de novembre 2025 est l'exemple type : le chemin d'upscaling utilisait mulDown (troncature), tandis que le solveur d'invariant calculait les entrées contre la sortie tronquée. Le protocole facturait légèrement moins qu'il ne le devait par swap. Enchaîné dans une seule transaction batchSwap à travers des milliers d'opérations de pool, l'écart s'est composé en une réserve drainable de 128 M$.
| Variante | Technique | Impact |
|---|---|---|
| Salami slicing classique | Émettre des millions de micro-transactions, chacune extrayant des gains sub-centime via arrondi par plancher/troncature (intérêts, paie, micro-dépôts) | Mise à l'échelle quadratique — 23 000 €/jour à 100 req/s sur la dénomination FX minimale |
| Arbitrage de devise (taux obsolètes) | Sélectionner une devise à faible coût à un taux de change fixé/mis en cache qui a divergé du spot ; payer systématiquement moins en équivalent USD | HackerOne #1677155 PortSwigger — sévérité informationnelle, perte financière réelle à grande échelle |
| Champ de calcul contrôlé par le client | Le serveur lit mf-calculation, total, amount depuis le corps de la requête et le transmet à Stripe sans recalcul depuis le catalogue produit | CVE-2026-1782 MetForm Pro — n'importe quel produit achetable pour 0,01 $ sans authentification |
Bypass zéro signé (quant=-0) | IEEE 754 définit -0.0 == +0.0 mais la multiplication peut produire des résultats négatifs ; le total se réduit exactement à zéro | CVE-2024-50968 — paiement gratuit, aucune auth requise |
| Réutilisation de PaymentIntent | Confirmer un PaymentIntent à 0,01 $, réutiliser le token dans un paiement de plus haute valeur ; le serveur ne valide que status=succeeded | CVE-2026-2890 Formidable Forms — bypass de tout formulaire de paiement sur les installations WordPress vulnérables |
Limite form_id=0 | Le serveur dérive la validation de paiement de form_id ; le mettre à une valeur sentinelle bypasse entièrement la validation du montant | CVE-2026-4987 SureForms — Stripe intent créé pour tout montant contrôlé par l'attaquant |
Le Sylius PayPalPlugin (versions < 1.6.1, < 1.7.1, < 2.0.1, patché en mars 2025) transmet le total du panier à PayPal à l'initiation du paiement. Si l'utilisateur ajoute des lignes après la création de l'intent PayPal, PayPal capture seulement le montant original (inférieur), tandis que Sylius marque la commande comme entièrement payée contre le total modifié. CVSS 6.5, vecteur CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N. Une faille de synchronisation d'état de paiement — le serveur accepte un résultat financier sans revalider la source faisant autorité. Source : GitHub Advisory GHSA-pqq3-q84h-pj6x.
quant=-0)itsourcecode Agri-Trading Online Shopping System 1.0 ne valide pas le paramètre quant dans l'endpoint Add to Cart. Soumettre quant=-0 fait que le calcul de prix price * qty produit zéro. L'attaquant règle sans frais. CVSS 7.5. Le bug exploite la particularité IEEE 754 selon laquelle -0.0 == +0.0 est vrai mais (-0.0) * x retourne -0.0, que l'agrégation naïve traite comme zéro. Une garde if quant <= 0 or quant != int(quant): reject() bloque l'attaque. Source : NVD CVE-2024-50968.
MetForm Pro pour WordPress (≤ 3.9.7, avril 2026) fait directement confiance au champ mf-calculation dans les soumissions de formulaire REST comme montant de charge Stripe/PayPal, sans recalcul côté serveur contre la tarification configurée. Un attaquant non authentifié soumet "mf-calculation": "0.01" et achète tout produit à ce prix. CVSS 5.3, CWE-20. Le pattern canonique 2026 de calcul contrôlé par le client et la variante dominante d'OWASP BLA3:2025 dans la nature. Source : cvefeed.io CVE-2026-1782.
form_id=0 SureFormsSureForms pour WordPress (≤ 2.5.2, avril 2026) dérive la validation de paiement dans create_payment_intent() du form_id fourni par l'utilisateur. Mettre form_id=0 bypasse entièrement la validation de montant configurée ; le serveur crée un Stripe PaymentIntent pour un montant contrôlé par l'attaquant sans consulter la configuration du formulaire. CVSS 7.5, non authentifié. Source : SentinelOne CVE-2026-4987.
Un rapport divulgué contre la plateforme commerce de PortSwigger a montré que la tarification multi-devises fixe crée des fenêtres d'arbitrage lorsque les taux de change réels dérivent des taux stockés. Un acheteur choisissant l'EUR à un taux obsolète payait substantiellement moins en équivalent USD que le prix USD listé. Sévérité informationnelle, prime de 0 $ selon la politique du programme, mais reportable sur toute plateforme offrant la sélection de devise sans rafraîchissement de taux par transaction. Source : HackerOne report #1677155.
Shipt validait les quantités du panier avec des contrôles entiers uniquement mais acceptait des décimales fractionnaires dans le corps de la requête. Soumettre qty=0.5 pour un article à 20 $ produisait une ligne à 10 $ que le validateur entier manquait. Prime de 100 $ accordée en 2018 — notable car la plupart des rapports de manipulation de prix reçoivent 0 $ (les programmes scope la logique métier comme informationnelle). Le pattern reste dominant : validation entière sur un champ numérique que la couche math traite comme un float. Source : HackerOne report #388564.
L'incident Balancer V2 de novembre 2025 (perte de 128 M$, analyse Trail of Bits) et la divulgation de l'exchange Bitcoin itBit de 2017 (HackerNews #13784755) étendent le pattern des paiements web à la DeFi et aux exchanges centralisés. Les mathématiques sont identiques ; seuls le débit et les tailles de réserves diffèrent.
Burp Suite Repeater est l'outil principal. Cartographier chaque endpoint acceptant un champ monétaire numérique — /checkout, /cart/add, /exchange, /transfer, /payment-intent, plus toute soumission de formulaire REST portant amount, total, price, calculation, qty. Pour chaque endpoint, exécuter :
0.001, 0.0049, 0.0050, 0.0051. Une incohérence entre l'arrondi des lignes et celui du total panier est reportable.quant=-0, amount=-0.0. Tout paiement à 0 $ réussi est le pattern CVE-2024-50968.qty=0.5 pour un article à prix entier (pattern Shipt #388564).calculation, total, mf-calculation, final_price par 0.01 ; observer si le serveur recalcule depuis le catalogue (pattern CVE-2026-1782).payment_intent_id, le substituer dans un paiement de plus haute valeur. Si amount_received n'est pas validé, l'exécution se poursuit (pattern CVE-2026-2890).0.01 USD → EUR → USD à répétition ; un solde net positif est le pattern ACROS Security 2012 (23 000 €/jour documentés).form_id — pour les plugins de paiement WordPress, soumettre form_id=0, form_id=-1 (pattern CVE-2026-4987).L'analyse statique attrape le problème structurel : types virgule flottante dans des contextes monétaires. Une règle Semgrep :
rules:
- id: float-in-monetary-context
patterns:
- pattern-either:
- pattern: float $PRICE = ...
- pattern: double $AMOUNT = ...
- pattern: Number($PRICE)
message: "Floating-point type used for monetary value — use BigDecimal, decimal.Decimal, or integer cents (CWE-1339)"
languages: [java, kotlin, python, javascript, typescript]
severity: WARNING
metadata:
cwe: CWE-1339
owasp: BLA3:2025CodeQL ajoute un raisonnement de dataflow : tracer les variables float/double qui flux vers les sinks de fournisseurs de paiement (Stripe amount, PayPal total). Selon la comparaison SAST 2026 de Rafter, CodeQL surpasse les pattern matchers dans cette catégorie car il trace la chaîne de calcul de bout en bout. Pour Python, pylint avec un checker personnalisé signale les littéraux float() où Decimal est attendu, et mypy en mode strict applique un domaine de type Money à travers la base de code.
Les scanners DAST seuls ne peuvent pas détecter cette classe. Burp Active Scan et OWASP ZAP ne comprennent pas quels champs représentent de l'argent ni quelle devrait être la charge attendue — ils n'ont aucun oracle pour « cet utilisateur a payé 0,01 $ pour un produit à 299 $ ». La détection nécessite une revue de code source ou un harnais de pentest avec état qui connaît la vérité terrain du catalogue.
BreachVex détecte cela via un fuzzing numérique typé sur les champs montant, quantité et total incluant l'injection de zéro signé (-0, -0.0), les valeurs limites IEEE 754, la substitution de champ de calcul client contre un snapshot de catalogue de référence, et la réutilisation de PaymentIntent à travers des flux de commande distincts. Les findings sont confirmés lorsque le différentiel entre la charge soumise et attendue est observé dans une réponse de transaction vérifiée.
La règle canonique : stocker l'argent en entiers (centimes, satoshis, millicentimes) et calculer en espace entier. Arrondir une seule fois, à la couche de présentation. Lorsque les entiers ne sont pas pratiques, utiliser decimal.Decimal (Python) ou BigDecimal (Java) avec un mode d'arrondi explicite.
# WRONG — never do this
price = 0.1 + 0.2 # 0.30000000000000004
tax = price * 0.08 # 0.024000000000000004
total = round(price + tax, 2)
# Drift compounds across thousands of orders.
# CORRECT — Decimal with explicit rounding
from decimal import Decimal, ROUND_HALF_EVEN
CENT = Decimal("0.01")
def compute_total(unit_price_cents: int, qty: int, tax_bps: int) -> int:
"""All integer arithmetic; no float involved."""
subtotal_cents = unit_price_cents * qty
tax_cents = (subtotal_cents * tax_bps + 5000) // 10000 # half-up
return subtotal_cents + tax_cents
# OR, when integers are impractical (FX, percentages):
def round_currency(amount: Decimal) -> Decimal:
# Banker's rounding — unbiased for large aggregates
return amount.quantize(CENT, rounding=ROUND_HALF_EVEN)L'équivalent Java utilise BigDecimal avec le constructeur String — jamais new BigDecimal(0.08), qui hérite de l'imprécision float (0.08000000000000000166533...). Utiliser new BigDecimal("0.08") ou BigDecimal.valueOf(8, 2). Appliquer setScale(2, RoundingMode.HALF_UP) uniquement au point d'émission final.
Les schémas de base de données comptent : amount FLOAT est un finding immédiat, amount DECIMAL(10,2) est acceptable pour le stockage mais perd la précision sub-centime lorsque les calculs intermédiaires quittent la base de données. L'équipe d'ingénierie de Modern Treasury stocke tous les USD en int64 (PostgreSQL bigint) précisément pour éliminer cette dérive.
La vague de CVE 2025–2026 exploite la confiance dans les montants fournis par le client et les références de paiement obsolètes. Deux contrôles ferment la brèche :
// Node.js / Stripe — verify intent amount before fulfilment
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
async function fulfilOrder(orderId, paymentIntentId) {
// 1. Recompute expected charge from the authoritative catalogue
const order = await db.order.findUnique({
where: { id: orderId },
include: { items: { include: { product: true } } },
});
const expectedCents = order.items.reduce(
(sum, item) => sum + item.product.priceCents * item.qty,
0,
);
// 2. Bind the intent to this specific order — reject reuse
if (order.paymentIntentId !== paymentIntentId) {
throw new Error('PaymentIntent does not match order');
}
// 3. Retrieve from Stripe — never trust client-supplied status
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
// 4. Validate amount AND status
if (intent.status !== 'succeeded') {
throw new Error(`Intent status ${intent.status}, expected succeeded`);
}
if (intent.amount_received !== expectedCents) {
throw new Error(
`Amount mismatch: received ${intent.amount_received}, expected ${expectedCents}`,
);
}
// 5. Idempotent transition to paid
await db.order.update({
where: { id: orderId, status: 'pending_payment' },
data: { status: 'paid', paidAt: new Date() },
});
}Cinq contrôles de durcissement obligatoires :
amount_received == expected_amount à l'exécution via l'API du fournisseur.form_id=0 et les valeurs limites dans les endpoints qui dérivent la tarification de la configuration de formulaire.Pour l'arbitrage de devise, récupérer les taux depuis une source faisant autorité au paiement, lier le taux à l'enregistrement de commande, et refuser les devis plus anciens qu'un TTL court (60–300 secondes). Appliquer du rate limiting sur les endpoints FX.
-0) partagent le pattern d'évasion regex.L'abus d'arrondi monétaire est une faille de logique métier où des attaquants extraient de la valeur en exploitant la façon dont une application convertit, additionne ou arrondit des montants monétaires. Il correspond à CWE-682 (Incorrect Calculation), CWE-1339 (Insufficient Numeric Precision) et OWASP BLA3:2025 Object State Manipulations. Les variantes modernes incluent CVE-2026-1782 (MetForm Pro `mf-calculation` fourni par le client) et l'incident Balancer V2 de novembre 2025 (128 M$ drainés via l'asymétrie `mulDown`/`mulUp`), prouvant que cette classe passe à l'échelle des paiements web jusqu'aux réserves DeFi.
Le salami slicing est la pratique consistant à émettre des millions de micro-transactions, chacune extrayant un montant inférieur au centime via un arrondi par troncature ou plancher, de sorte que la perte par événement échappe à la détection alors que le cumul reste matériel. La divulgation ACROS Security de 2012 a documenté 23 000 € par jour extraits à environ 100 requêtes par seconde contre une banque en ligne européenne en utilisant la dénomination FX minimale. Les équivalents modernes ciblent les calculs d'intérêts, les retenues sur paie et les micro-dépôts en cryptomonnaie où la direction d'arrondi est non uniforme.
Le type `Number` de JavaScript est un double IEEE 754 64 bits, qui représente les fractions en binaire. Le décimal `0.1` n'a pas de représentation binaire exacte — le double le plus proche est `0.1000000000000000055511151231257827021181583404541015625`. Sommer trois de ces valeurs donne `0.30000000000000004`, et non `0.3`. Le même comportement existe en Python `float`, Java `double`, et dans la FPU de chaque CPU moderne. Pour le code monétaire, utilisez des centimes entiers, `decimal.Decimal` (Python) ou `BigDecimal` (Java) construit à partir d'une chaîne — jamais à partir d'un littéral float.
OWASP BLA3:2025 (Object State Manipulations) est la troisième entrée du Top 10 OWASP pour Business Logic Abuse, publié en 2025. Elle nomme directement la variante moderne d'abus d'arrondi : champs numériques acceptés du client sans recalcul côté serveur contre le catalogue faisant autorité. La vague de CVE 2026 (CVE-2026-1782 MetForm Pro, CVE-2026-2890 Formidable Forms, CVE-2026-4987 SureForms) est BLA3 en production — les serveurs font confiance à `mf-calculation`, `total` ou `form_id=0` et transmettent la valeur à Stripe sans recalculer la charge attendue.
Cinq contrôles obligatoires. (1) Stockez l'argent en centimes entiers et calculez en espace entier ; arrondissez uniquement à la couche de présentation. (2) Recalculez chaque charge côté serveur depuis le catalogue produit — ne faites jamais confiance aux champs `amount`, `total` ou `mf-calculation` fournis par le client. (3) Validez `payment_intent.amount_received == expected_amount` avant l'exécution via l'API du fournisseur. (4) Liez les PaymentIntents à des IDs de commande à la création et rejetez la réutilisation entre commandes (pattern CVE-2026-2890). (5) Rejetez les valeurs limites comme `form_id=0`, `qty=-0` et les quantités fractionnaires sur des articles à prix entier (HackerOne #388564, CVE-2024-50968).
IEEE 754 est la norme de virgule flottante binaire de 1985 implémentée par la FPU de chaque CPU — `float`, `double` et JavaScript `Number` sont tous IEEE 754. Elle stocke les fractions en base 2, donc `0.1` est inexact, et l'arithmétique accumule des erreurs d'arrondi. `Decimal` (Python `decimal.Decimal`, Java `BigDecimal`, .NET `decimal`) stocke les nombres en base 10 avec une précision arbitraire et des modes d'arrondi explicites. Pour la monnaie, IEEE 754 n'est pas sûr — l'équipe d'ingénierie de Modern Treasury stocke tous les USD en `int64` précisément parce que la dérive des float s'aggrave sur des millions de transactions.
Le banker's rounding (round half to even, le mode par défaut IEEE 754 et préféré par les GAAP) est mathématiquement non biaisé pour de grands cumuls — `0,005 $` arrondit à `0,00 $`, `0,015 $` arrondit à `0,02 $`. Il n'est pas exploitable seul. L'exploit apparaît lorsque deux modes d'arrondi coexistent dans une même transaction — par exemple, des lignes arrondies en half-up tandis que le total du panier utilise le banker's rounding. Cette asymétrie est exactement le pattern Balancer V2 (`mulDown` upscaling contre un solveur d'invariant attendant un arrondi symétrique) qui a drainé 128 M$ en novembre 2025. Choisissez un mode par domaine, documentez-le et appliquez-le via un utilitaire partagé.