Contournement de workflow (CWE-841) : appel direct des endpoints finaux pour sauter le paiement, le KYC ou la vérification email sans passer par les étapes intermédiaires.
TL;DR
Un contournement d'étape de workflow est une vulnérabilité de logique métier dans laquelle un processus multi-étapes — combinant autorisation, paiement, vérification d'identité ou revue de conformité — est court-circuité en envoyant directement la requête de l'étape finale sans compléter les prérequis. Il est catalogué comme CWE-841 (Improper Enforcement of Behavioral Workflow) et constitue le focus de WSTG-BUSL-06 dans le OWASP Web Security Testing Guide. Il apparaît également dans OWASP A04:2021 — Insecure Design (renommée A06:2025 — Insecure Design dans la mise à jour OWASP Top 10:2025) et comme entrée dédiée Workflow Order Bypass dans le OWASP Top 10 for Business Logic Abuse 2025.
La caractéristique déterminante est un décalage entre l'expérience côté client et le modèle d'application côté serveur. Les développeurs construisent une UI linéaire propre — un assistant de checkout avec boutons "Suivant" désactivés, une page KYC qui cache "Approuver" tant que les documents ne sont pas téléversés — et supposent que l'utilisateur ne peut pas atteindre l'étape N sans compléter l'étape N-1. L'erreur est que les endpoints API derrière ces étapes UI sont accessibles indépendamment. Un attaquant utilisant Burp Suite ou curl peut appeler directement POST /api/v1/checkout/complete et — si le serveur n'interroge pas la colonne d'état du workflow en base avant de traiter la requête — la commande est créée sans paiement.
La distinction entre application front-end et application back-end constitue toute la vulnérabilité. La logique front-end guide les utilisateurs sur le chemin heureux ; la logique back-end est la seule chose qui protège les invariants métier. Chaque étape ayant une signification financière, sécuritaire ou de conformité doit être modélisée comme une transition d'état server-side que les étapes ultérieures vérifient explicitement. Tout ce qui est moindre est décoratif.
Un workflow multi-étapes est fondamentalement une machine à états finis (FSM) sur une transaction ou une session. Le serveur doit suivre l'état dans lequel se trouve chaque transaction (CART, ADDRESS, PAYMENT_PENDING, PAYMENT_CONFIRMED, COMPLETE) et rejeter toute requête tentant une transition non autorisée depuis l'état courant. Lorsque cette vérification est manquante ou partielle, n'importe quel endpoint ultérieur devient atteignable depuis l'état initial.
L'étape critique sautable varie selon le domaine. Le tableau ci-dessous liste les formes courantes de workflow où ce pattern récurre :
| Flow | Étape critique sautable | Impact typique |
|---|---|---|
| Checkout e-commerce | Collecte du paiement | Commandes gratuites, fraude financière |
| KYC / vérification d'identité | Téléversement de pièce d'identité + liveness check | Échec de conformité AML, risque de blanchiment |
| Inscription de compte | Vérification email | Account takeover via squatting, spam |
| Activation d'abonnement | Confirmation du moyen de paiement | Accès gratuit indéfini au tier payant |
| Réinitialisation de mot de passe | Validation de l'ancien mot de passe ou du token | Account takeover |
| Demande de prêt | Vérification de crédit + de revenus | Approbation frauduleuse de prêt |
Une requête d'attaque canonique :
POST /api/v1/checkout/complete HTTP/1.1
Host: api.shop.example.com
Authorization: Bearer <user_jwt>
Content-Type: application/json
{"cart_id": "abc123", "shipping_address_id": "addr_456"}Si le serveur retourne {"order_id": "ORD-789", "status": "confirmed"} sans appel préalable à /api/v1/checkout/payment, le paiement a été contourné. L'attaquant n'a injecté aucun payload — il a simplement appelé le dernier endpoint en premier.
| Variante | Technique | Impact |
|---|---|---|
| Navigation URL directe | Naviguer vers /checkout/confirmation ou /order/success?id=... directement dans une session fraîche | Commande confirmée sans page de paiement |
| Appel API direct hors séquence | POST /api/v1/subscription/activate sans /api/v1/payment/charge préalable | Activation d'abonnement gratuit |
| Forçage de paramètre | Modifier step=payment en step=complete ou injecter payment_status=paid dans le corps du formulaire | Le serveur fait confiance à l'état fourni par le client |
| Replay de requête en session fraîche | Capturer la requête de l'étape N dans Burp Repeater, la rejouer avec de nouveaux cookies / sans étapes préalables | Révèle des endpoints stateless sans vérification de prérequis |
| Webhook spoofing | Falsifier POST /api/webhooks/stripe avec un payload de succès factice, sans signature HMAC | Abonnement marqué payé sans implication du provider |
| Cycle de plan caché / feature flag | Basculer vers un plan interne dev via un /api/plan/switch non documenté pour réinitialiser l'essai (SaaS) | Accès gratuit indéfini au tier Enterprise |
| Race sur sous-état de machine à états (CWOB) | Attaque single-packet HTTP/2 synchronisant deux requêtes dans une fenêtre de sous-état ~1ms (Kettle 2023) | Course pour devancer un downgrade de privilèges ou un check de paiement |
La variante CWOB — Checkout Without Borders dans le vocabulaire post-Kettle — défait le pattern défensif le plus courant : un handler séquentiel check-then-act. La recherche PortSwigger d'août 2023 de James Kettle, "Smashing the State Machine", a montré que chaque requête HTTP fait transiter une application à travers des sous-états internes dans des fenêtres ~1ms, et que l'attaque single-packet HTTP/2 — regroupant 30 requêtes dans la dernière frame d'une connexion HTTP/2 via Burp Turbo Intruder ou h2spacex — les synchronise dans une fenêtre d'exécution sub-1ms depuis un hôte distant. Cela transforme les race conditions distantes en races locales, rendant l'exploitation des sous-états viable contre toute FSM exposée à internet sans transitions atomiques.
La classe de vulnérabilité est exploitée de manière constante aux niveaux de sévérité les plus élevés dans les bases CVE et les plateformes de bug bounty.
CVE-2023-28121 — WooCommerce Payments (CVSS 9.8 CRITICAL, exploité activement)
Les versions 4.8.0–5.6.1 du plugin WooCommerce Payments ne vérifiaient pas un header HTTP fourni par l'utilisateur (X-WCPay-Platform-Checkout-User) ; l'injecter avec l'ID utilisateur d'un administrateur permettait à un attaquant non authentifié d'usurper n'importe quel utilisateur, contournant l'étape d'authentification de chaque workflow protégé (checkout, gestion de commandes, installation de plugin). L'exploitation de masse a commencé en juillet 2023. Vecteur CVSS : CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
CVE-2025-31161 — CrushFTP (CVSS 9.8 CRITICAL, exploité activement)
Une race condition dans le handler d'autorisation AWS4-HMAC de CrushFTP 10.0.0–10.8.3 et 11.0.0–11.3.0. Le serveur appelle login_user_pass() sans mot de passe pour vérifier l'existence de l'utilisateur, authentifie la session via HMAC, puis attend une seconde vérification de privilèges. La fenêtre ~5ms entre sous-états permet à un attaquant d'émettre des requêtes admin authentifiées avant que le downgrade de privilèges ne s'exécute — un CWOB de manuel. Premier exploit dans la nature le 31 mars 2025 (Shadowserver). La correction en 10.8.4 / 11.3.1 a rendu la transition verify-then-downgrade atomique.
CVE-2025-14971 — Link Invoice Payment for WooCommerce (CVSS 5.3, 30 jan 2026). Les fonctions REST API createPartialPayment et cancelPartialPayment dans les versions ≤ 2.8.0 n'effectuent aucun contrôle de capacité ; un attaquant non authentifié peut créer des paiements partiels frauduleux ou annuler des paiements légitimes sur n'importe quelle commande en énumérant les IDs (CWE-862).
HackerOne #682617 — Starbucks Suisse (divulgué). Le chercheur a fabriqué un message de succès de paiement falsifié vers l'endpoint de callback de paiement card.starbucks.ch, créditant une carte Starbucks suisse sans transaction réelle — falsification de callback de paiement en production.
HackerOne #423546 — Shopify Wholesale (H1-514 Live Hacking Event). Le canal Wholesale permet aux boutiques de désactiver le checkout pour que les bons de commande nécessitent une revue par le staff. Le chercheur a trompé le routeur de checkout pour compléter un checkout normal (non revu) même avec checkout explicitement désactivé, contournant l'étape de revue obligatoire par le staff.
HackerOne #953083 — Shopify Theme Store (bounty 2 000 $). L'attaquant a publié un thème tiers payant sur sa boutique sans l'acheter en envoyant un XHR ThemePublishLegacy pendant qu'une installation de thème était en cours. Le workflow d'installation (add-to-cart → paiement → licence → install → publish) a été contourné en sautant à publish pendant la fenêtre d'installation — une race sur sous-état du monde réel.
HackerOne #1328278 — Stripe (divulgué). Les utilisateurs pouvaient acheter un produit à un prix archivé (désactivé) via un lien de paiement précédemment généré. Le traitement des liens par Stripe ne validait pas que le lien et le prix étaient tous deux actifs au moment du checkout.
HackerOne #1420697 — lemlist (divulgué). Une mauvaise gestion du moyen de paiement sur app.lemlist.com permettait à un attaquant d'activer un plan payant sans compléter de transaction. L'endpoint d'activation ne vérifiait jamais la confirmation du provider de paiement.
HackerOne #2012443 — Nord Security / NordVPN (divulgué). Une faille dans le service backend vérifiant le statut d'abonnement actif pouvait être contournée, accordant l'accès VPN à des utilisateurs sans abonnement valide (scope session).
HackerOne #2170559 — Cloudflare R2 (18 sept 2023, divulgué). Des contrôles d'accès insuffisants permettaient à un utilisateur d'activer le stockage objet R2 sans moyen de paiement enregistré — le prérequis "moyen de paiement valide" du workflow d'activation n'était pas appliqué côté serveur.
La méthodologie Burp Repeater suit un protocole de test CWOB en cinq étapes tiré du lab Insufficient Workflow Validation de PortSwigger et de la recherche post-CWOB :
/checkout/complete, /subscription/activate, /kyc/approve).step, stage, status, payment_status, verified, confirmed dans les corps de requête. Les forcer à un état ultérieur.h2spacex basée sur Scapy effectue la synchronisation last-frame HTTP/2 pour un timing précis de race condition.Pour les endpoints webhook, chercher dans les bundles JS et les docs OpenAPI des chemins comme /webhooks/stripe. Envoyer un corps JSON conçu mimant le format de succès du provider (par ex. {"type": "payment_intent.succeeded", "data": {"object": {"id": "pi_xxx"}}}) sans signature. Si l'application marque le paiement complet, la vérification HMAC est absente.
Les scanners DAST traditionnels (Burp Active Scan, OWASP ZAP) ne peuvent pas détecter le contournement de workflow par conception — ils testent des requêtes individuelles sans comprendre les machines à états métier. Des outils dédiés ont émergé en 2025–2026 pour combler ce gap :
BreachVex détecte cette classe via un sondage sequence-aware des endpoints multi-étapes combiné à la validation de signature webhook. Le scanner mappe les endpoints API découverts en clusters de workflow (checkout, abonnement, KYC, password reset, inscription), puis pour chaque cluster envoie la requête d'étape finale inférée dans une session authentifiée fraîche sans étapes préalables. Les findings sont marqués POTENTIAL_WORKFLOW_BYPASS quand l'endpoint final retourne HTTP 200 avec des mots-clés order/confirmation/activation ; ils sont promus CONFIRMED uniquement après qu'une vérification de comparaison d'état observe un changement d'état server-side. Les endpoints webhook sont sondés avec un payload de succès falsifié non signé ; si la réponse indique une mutation d'état, un finding séparé est émis avec une chaîne CWE-345 / CWE-841.
La seule défense durable est de modéliser chaque workflow multi-étapes comme une machine à états finis sur le serveur, avec l'état stocké en base (ou dans un magasin de session server-side) et jamais dérivé de paramètres fournis par le client. Chaque transition doit valider l'état courant avant de s'exécuter.
# VULNERABLE — no state check, server trusts that the front-end called payment first
@require_POST
def complete_order_vulnerable(request):
checkout_id = request.POST["checkout_id"]
checkout = Checkout.objects.get(id=checkout_id)
order = create_order(checkout) # No payment check, no state guard
return JsonResponse({"order_id": order.id, "status": "confirmed"})
# FIXED — explicit FSM with current_step column in DB and transition table
class CheckoutState(models.TextChoices):
CART = "cart"
ADDRESS = "address"
PAYMENT_PENDING = "payment_pending"
PAYMENT_CONFIRMED = "payment_confirmed"
COMPLETE = "complete"
CANCELLED = "cancelled"
class Checkout(models.Model):
id = models.UUIDField(primary_key=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
current_step = models.CharField(
max_length=32,
choices=CheckoutState.choices,
default=CheckoutState.CART,
)
payment_intent_id = models.CharField(max_length=64, null=True)
payment_confirmed_at = models.DateTimeField(null=True)
ALLOWED_TRANSITIONS = {
CheckoutState.CART: {CheckoutState.ADDRESS, CheckoutState.CANCELLED},
CheckoutState.ADDRESS: {CheckoutState.PAYMENT_PENDING, CheckoutState.CANCELLED},
CheckoutState.PAYMENT_PENDING: {CheckoutState.PAYMENT_CONFIRMED, CheckoutState.CANCELLED},
CheckoutState.PAYMENT_CONFIRMED: {CheckoutState.COMPLETE, CheckoutState.CANCELLED},
}
@require_POST
def complete_order(request):
checkout = get_object_or_404(
Checkout, id=request.POST["checkout_id"], user=request.user
)
if checkout.current_step != CheckoutState.PAYMENT_CONFIRMED:
return JsonResponse(
{
"error": "WORKFLOW_STATE_INVALID",
"current_state": checkout.current_step,
"required_state": CheckoutState.PAYMENT_CONFIRMED,
},
status=400,
)
order = create_order(checkout)
checkout.current_step = CheckoutState.COMPLETE
checkout.save()
return JsonResponse({"order_id": order.id, "status": "confirmed"})Ne jamais laisser le client signaler le succès du paiement. Utiliser des webhooks server-to-server avec vérification de signature cryptographique sur chaque événement avant toute mutation d'état.
# Stripe webhook signature verification (Python) — REQUIRED for every payment webhook
import os
import stripe
from flask import request
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] # whsec_...
@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
payload = request.get_data() # raw bytes — required for HMAC
sig_header = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except (ValueError, stripe.error.SignatureVerificationError):
# Reject unsigned or forged events — this is the bypass-prevention line
return "", 400
# Only after signature verification, mutate state
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
checkout = Checkout.objects.get(payment_intent_id=intent["id"])
checkout.current_step = CheckoutState.PAYMENT_CONFIRMED
checkout.payment_confirmed_at = timezone.now()
checkout.save()
return "", 200L'équivalent Node.js utilise stripe.webhooks.constructEvent(rawBody, signature, secret) avec le corps brut de la requête. Parser le JSON d'abord puis le re-stringifier casse le HMAC car la représentation canonique en bytes est perdue — un bug silencieux courant.
Pour défaire l'attaque single-packet CWOB post-2023, chaque transition d'état doit être enveloppée dans une transaction base de données avec un verrou au niveau ligne acquis via SELECT FOR UPDATE. Cela sérialise les tentatives concurrentes de read-then-write sur la même ligne de workflow, éliminant la fenêtre sub-état ~1–5ms ciblée par les attaques single-packet.
from django.db import transaction
def complete_order_atomic(checkout_id, user):
with transaction.atomic():
checkout = (
Checkout.objects
.select_for_update() # row-level lock — serializes concurrent requests
.get(id=checkout_id, user=user)
)
if checkout.current_step != CheckoutState.PAYMENT_CONFIRMED:
raise WorkflowStateError(
current=checkout.current_step,
required=CheckoutState.PAYMENT_CONFIRMED,
)
checkout.current_step = CheckoutState.COMPLETE
checkout.save()
order = create_order(checkout)
return orderLe SELECT FOR UPDATE est la ligne la plus importante pour la défense contre les races — sans lui, deux requêtes concurrentes peuvent toutes deux observer PAYMENT_CONFIRMED, toutes deux faire passer l'état à COMPLETE, et toutes deux créer des commandes. Avec lui, la seconde requête bloque jusqu'au commit de la première, relit le nouvel état, et est rejetée. Combiné avec l'application FSM et les webhooks vérifiés HMAC, cela ferme la classe de contournement de workflow contre l'attaque classique de saut et le CWOB single-packet HTTP/2.
Le contournement d'étape de workflow est une faille de logique métier où un processus multi-étapes — checkout, KYC, vérification email, activation d'abonnement — est court-circuité en appelant directement l'endpoint de l'étape finale sans compléter les prérequis. Mappé à CWE-841 (Improper Enforcement of Behavioral Workflow), exposé par OWASP WSTG-BUSL-06, et listé sous OWASP A04:2021 (renommé A06:2025 — Insecure Design). Le nouveau OWASP Top 10 for Business Logic Abuse 2025 nomme Workflow Order Bypass comme catégorie dédiée. Cas réels : CVE-2023-28121 et HackerOne #2170559 (Cloudflare R2).
En identifiant l'endpoint final (POST /api/v1/checkout/complete ou /orders/finalize), en le capturant dans Burp Repeater pendant un flow normal, puis en le rejouant dans une session fraîche sans jamais appeler l'endpoint de paiement. Si le serveur retourne un order_id et un statut confirmed, le paiement a été contourné. Cas réels : HackerOne #682617 (Starbucks Suisse, falsification du callback de paiement), HackerOne #1420697 (lemlist activation directe), HackerOne #2170559 (Cloudflare R2 stockage payant sans moyen de paiement, septembre 2023).
CWOB est le vocabulaire post-Kettle pour les race conditions sur sous-états dans les handlers de workflow, popularisé après le papier d'août 2023 de PortSwigger Research 'Smashing the State Machine'. L'exploit utilise l'attaque single-packet HTTP/2 pour synchroniser 30 requêtes dans une fenêtre sub-1ms, exploitant l'écart entre les sous-états d'authentification ou la validation pré-paiement. CVE-2025-31161 (CrushFTP, CVSS 9.8 Critical, exploité activement en mars 2025) est le cas canonique CWOB : le handler AWS4-HMAC avait une fenêtre ~5ms entre sous-états d'auth permettant le contournement CWOB.
CVE-2023-28121 affecte WooCommerce Payments 4.8.0–5.6.1 (CVSS 9.8 Critical, vecteur CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Le bug est un contournement d'authentification non authentifié via le header HTTP X-WCPay-Platform-Checkout-User — fournir un user_id cible dans ce header permet d'usurper cet utilisateur sans aucun mot de passe ni token. Les attaquants l'ont utilisé pour prendre le contrôle de comptes admin sur des dizaines de milliers de sites WordPress. Exploité activement quelques jours après la divulgation ; corrigé en 5.6.2 et propagé via les mises à jour automatiques WordPress.
Le webhook spoofing falsifie un POST /api/webhooks/stripe (ou un callback équivalent du provider) avec un payload de succès factice, marquant l'abonnement/paiement comme complet sans implication du provider. Prévention : vérifier la signature HMAC sur chaque webhook avec stripe.Webhook.construct_event(payload, sig_header, secret) en Python, ou l'appel SDK Stripe équivalent dans n'importe quel langage. La signature doit être calculée sur le corps brut de la requête avant tout parsing JSON. HackerOne #682617 (Starbucks) et la divulgation 2018 de Jack Cable sont des exemples canoniques ; le pattern apparaît encore dans les audits 2024–2026.
Jamais. Tout champ d'état fourni par le client (step=complete, payment_status=paid, kyc_verified=true, plan=enterprise) est contrôlé par l'attaquant. Le serveur doit dériver l'état du workflow en interrogeant l'enregistrement canonique (orders.payment_status, kyc_records.status) à l'intérieur de la même transaction qui effectue la transition d'état suivante. La seule hypothèse sûre est que tout champ client est hostile, même si l'UI officielle est bien comportée. L'application server-side d'une machine à états avec tables de transition explicites est la correction structurelle.
Définir une FSM explicite avec les transitions autorisées : CART → ADDRESS → PAYMENT_PENDING → PAYMENT_CONFIRMED → COMPLETE. Stocker current_step dans la ligne du workflow. Sur chaque requête modifiant l'état : BEGIN TRANSACTION; SELECT current_step FROM workflows WHERE id=? FOR UPDATE; vérifier que la transition demandée est dans la table des transitions autorisées ; UPDATE current_step; COMMIT. Le SELECT FOR UPDATE empêche les races CWOB. Rejeter toute requête qui ne correspond pas à la transition autorisée avec un 409 Conflict. CVE-2025-31161 s'est produit parce que ce pattern transactionnel manquait.