Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
CVE-2026-19264 — CVE-2026-19264 - Critical unauthenticated path traversal to full instance takeover in Postiz (< 2.22.1). Technical writeup: decode-order bypass, JWT_SECRET escalation, and analysis of the upstream fix. | Kitploit
Outils/GitHubGitHub/darklycn1976/cve-2026-19264
Vulnerability AnalysisCode AnalysisExploitationWeb Application ExploitationWeb SecurityLearning & Education
GitHubdarklycn1976/cve-2026-19264

CVE-2026-19264

CVE-2026-19264 - Critical unauthenticated path traversal to full instance takeover in Postiz (< 2.22.1). Technical writeup: decode-order bypass, JWT_SECRET escalation, and analysis of the upstream fix.

Voir le dépôt
il y a 10 joursPas encore vérifié

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager
Site web

CVE-2026-19264 — Traversée de chemin non authentifiée menant à une prise de contrôle totale de l'instance Postiz

CVE-2026-19264 — Traversée de chemin non authentifiée menant à une prise de contrôle totale de l'instance Postiz

Auteur : Krithik Babu P (@DarkLycn1976) Publié : 2026-08-10 CVE : CVE-2026-19264 Sévérité : Critical — CVSS 4.0 9.3 / CVSS 3.1 9.8 CWE : CWE-22 — Limitation inappropriée d'un nom de chemin à un répertoire restreint Versions affectées : gitroomhq/postiz-app < 2.22.1 Corrigé dans : v2.22.1


TL;DR

Postiz servait les médias stockés localement via une route qui concaténait les segments de chemin fournis dans l'URL au répertoire de téléversement, puis renvoyait le résultat en streaming — sans normalisation de chemin, sans vérification de confinement et sans authentification.

La charge utile de traversée évidente renvoie une 404, car Next.js réduit les segments ../ avant le routage. Mais les séparateurs encodés en URL survivent à la correspondance de routes et sont décodés exactement une fois de plus sur le chemin vers l'appel au système de fichiers, rétablissant la traversée de l'autre côté de chaque contrôle.

Un attaquant non authentifié pouvait lire tout fichier lisible par le processus de l'application — y compris son propre environnement, qui contient le secret de signature JWT. Comme Postiz signe les jetons de session avec ce secret et les émet sans date d'expiration, récupérer ce secret transforme une primitive de lecture de fichier en une session permanente et forgeable sous l'identité de n'importe quel utilisateur, y compris un administrateur.

Une seule requête GET non authentifiée pour une prise de contrôle totale de l'instance.


1. Contexte

Postiz est une plateforme open-source de planification de publications sur les réseaux sociaux — environ 34 000 étoiles GitHub au moment de la rédaction — construite avec un frontend Next.js et un backend NestJS. Elle est largement auto-hébergée par des agences et de petites équipes pour gérer des comptes de réseaux sociaux connectés, le contenu planifié et la facturation.

Les déploiements auto-hébergés peuvent stocker les médias téléversés localement plutôt que sur un stockage d'objets. Ce comportement est contrôlé par une seule variable d'environnement :

root@kitploit:~
STORAGE_PROVIDER=local

C'est la valeur fournie dans .env.example, c'est donc ce que la plupart des auto-hébergeurs utilisent, sauf s'ils configurent délibérément S3 ou Cloudflare R2.

2. La surface d'attaque

Lorsque le stockage local est actif, next.config.js réécrit le chemin public /uploads/:path* vers une route API interne :

root@kitploit:~
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts

Deux propriétés rendent cette route intéressante avant même qu'un bug n'intervienne :

  1. Elle n'est pas authentifiée. Aucun middleware frontend ne la protège. Servir des médias publics est l'intention, donc aucune session n'est requise.
  2. C'est un attrape-tout. Le segment attrape-tout optionnel [[...path]] signifie que chaque composant de chemin restant arrive sous forme de tableau que le handler est libre d'interpréter.

Lorsque STORAGE_PROVIDER a une valeur autre que local, la réécriture pointe vers /404 et le handler est inaccessible. Cette barrière de configuration est la seule chose qui se dresse entre un déploiement et ce bug.

3. Le code vulnérable

Le handler, avant la v2.22.1 :

root@kitploit:~
export const GET = async (request: NextRequest, context) => {
  const { path } = await context.params;
  const filePath =
    process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');

  const response  = createReadStream(filePath);
  const fileStats = statSync(filePath);
  // ... stream the file back to the caller
};

Trois défauts en quatre lignes :

  • Aucune normalisation. Ni path.normalize() ni path.resolve() n'est appelé. Quels que soient les segments reçus, ils sont concaténés tels quels.
  • Aucune vérification de confinement. Rien ne vérifie que le filePath résultant se trouve toujours à l'intérieur de UPLOAD_DIRECTORY.
  • Concaténation de chaînes, pas jonction de chemins. + '/' + traite les composants comme du texte, pas comme un chemin ayant une sémantique.

Le résultat va directement dans createReadStream() et les octets sont diffusés à l'appelant avec un type MIME déduit du nom de fichier. Il n'y a ni liste blanche d'extensions ni filtre de contenu.

4. Pourquoi la charge utile évidente échoue

L'attaque classique est :

root@kitploit:~
GET /uploads/../../../etc/passwd

Sur Postiz, elle renvoie une 404, et cette 404 est la raison même pour laquelle ce bug a survécu jusqu'à sa découverte.

Next.js normalise le chemin de la requête pendant le routage. Les segments ../ bruts sont réduits avant que le routeur décide quel handler invoquer. Au moment où la requête atteint l'attrape-tout, la traversée a déjà été éliminée — soit le chemin se résout vers un endroit sans route correspondante, soit il se résout à l'intérieur de /uploads, les segments point ayant disparu.

Pour quelqu'un qui teste rapidement, cette 404 se lit comme « le framework gère cela ». C'est une défense réelle et fonctionnelle. Le problème n'est pas qu'elle soit absente — c'est où dans le pipeline elle s'exécute.

5. Le contournement — un décalage dans l'ordre de décodage

La correspondance de routes et le handler de requête n'effectuent pas le même nombre de passes de décodage en pourcentage.

Si les séparateurs sont encodés en pourcentage, la séquence n'est pas un séparateur de chemin pendant la correspondance de routes. %2e%2e%2f n'est qu'une chaîne opaque — un texte inerte que le normaliseur n'a aucune raison de toucher. Elle traverse le routage intacte, correspond à l'attrape-tout, puis est décodée en chemin vers les params du handler, où elle redevient ../.

À ce stade, elle est concaténée à UPLOAD_DIRECTORY et transmise à createReadStream() — après avoir franchi le routage, la normalisation et chaque contrôle qui aurait pu l'arrêter.

Variantes fonctionnelles :

root@kitploit:~
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt        → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd            → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd   → 200, returned the real /etc/passwd

Le double encodage ne fonctionne pas — %252e reste littéral à travers l'unique passe de décodage et ne devient jamais un point. Exactement une couche d'encodage est le point idéal, ce qui rappelle utilement que « encoder plus fort » n'est pas une stratégie.

L'invariant à retenir :

Un contrôle qui s'exécute avant la fin du décodage ne protège pas le sink.

6. Escalade — de la lecture arbitraire à la prise de contrôle de l'instance

Une primitive de lecture de fichier est de gravité High à elle seule. Ce qui la rend Critical, c'est ce qu'elle permet d'atteindre.

Étape 1 — lire l'environnement. La configuration du processus Node lui-même est sur disque, à la racine du déploiement. .env permet d'obtenir, entre autres :

  • JWT_SECRET — la clé de signature des jetons de session
  • DATABASE_URL — les identifiants Postgres complets
  • Les secrets OAuth des fournisseurs connectés et les clés de facturation

Étape 2 — forger une session. Postiz signe les jetons de session avec JWT_SECRET en utilisant HS256 via jsonwebtoken. Point critique : les jetons sont émis sans expiresIn, si bien qu'un jeton forgé est valide indéfiniment.

Étape 3 — devenir n'importe qui. Le middleware d'authentification résout à nouveau l'utilisateur depuis la base de données à l'aide de la revendication id. Il ne fait délibérément pas confiance à une revendication telle que isSuperAdmin issue du jeton — bonne conception — mais ce durcissement devient sans objet dès que l'on peut signer un id arbitraire. Signer { id: <victim user id> } produit une session impossible à distinguer d'une connexion légitime :

root@kitploit:~
read .env  →  JWT_SECRET  →  sign({ id: victim })  →  authenticated as victim, forever

J'ai validé ce point contre la logique de vérification réelle du projet, avec la dépendance jsonwebtoken effective : un jeton signé avec le secret récupéré a été accepté, et le même jeton signé avec un mauvais secret a été rejeté. Le cas témoin compte — sans lui, vous avez une hypothèse, pas une découverte.

Étape 4 — le chemin parallèle. DATABASE_URL seul suffit pour un accès Postgres direct : lire chaque compte connecté ou basculer directement un drapeau administrateur.

Pas de mot de passe. Pas d'accès préalable. Pas d'interaction utilisateur. Une seule requête HTTP non authentifiée.

7. Impact et score

root@kitploit:~
CVSS 4.0  9.3  AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1  9.8  AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

PR:N et UI:N sont les deux métriques qui font le travail. La route n'exige ni session ni interaction de la victime — l'attaquant agit seul, sur le réseau, contre une configuration par défaut.

Le seul facteur limitant honnête est la barrière de configuration : les déploiements sur S3 ou R2 ne sont pas exposés, car la route réécrit vers /404. Cela réduit la population affectée, mais pas la sévérité pour quiconque en fait partie — et local est la valeur par défaut fournie.

8. Le correctif

Le correctif des mainteneurs (7936062) fait huit lignes et vaut la peine d'être lu, car il est correct, ce qui n'est souvent pas le cas de ce type de correctif :

root@kitploit:~
+import { resolve, sep } from 'path';
...
-  const filePath =
-    process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+  const base = resolve(process.env.UPLOAD_DIRECTORY!);
+  const filePath = resolve(base, (path ?? []).join('/'));
+  // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+  // (including URL-decoded ones), so this blocks every path-traversal variant.
+  if (filePath !== base && !filePath.startsWith(base + sep)) {
+    return new NextResponse('Not found', { status: 404 });
+  }

Deux choses qu'il fait correctement :

  1. Il normalise au sink. resolve() réduit .. une fois le décodage terminé, donc peu importe comment la traversée a été introduite en contrebande dans le routage. La vérification se situe désormais là où se trouve le danger.
  2. Il compare avec base + sep, pas avec base. Un filePath.startsWith(base) naïf accepterait /app/uploads-evil/x comme étant à l'intérieur de /app/uploads — un contournement classique de la correspondance par préfixe. L'ajout du séparateur élimine ce contournement, et la clause filePath !== base maintient le répertoire lui-même valide.

C'est la forme correcte pour une vérification de confinement : résoudre, puis comparer avec la base suivie d'un séparateur final.

9. Chronologie de la divulgation

Toutes les heures sont en UTC, 2026-07-20, sauf indication contraire.

HeureÉvénement
05:55Avis de sécurité signalé à l'équipe Postiz
07:44Reconnu et vérifié par les mainteneurs

Six heures et vingt-trois minutes entre le signalement et le correctif publié, sur un projet open-source sans programme de bug bounty associé. J'ai vu des signalements rester sans traitement pendant des mois dans des organisations dotées d'équipes de sécurité dédiées. Merci à Enno Gelhaus pour la coordination et à Nevo David pour la remédiation.

10. Points à retenir

Le fait qu'un contrôle passe votre test ne signifie pas qu'il est au bon endroit. La 404 était réelle. Next.js réduit véritablement ../. La défense s'exécutait simplement avant que l'entrée n'ait fini d'être décodée, ce qui signifiait qu'elle protégeait le routeur plutôt que l'appel au système de fichiers. Lorsque vous trouvez une mesure d'atténuation, demandez-vous quand elle s'exécute par rapport au sink — pas seulement si elle existe.

L'encodage est une couche, et les couches sont pelées à des rythmes différents. Chaque fois que deux composants d'un pipeline de requête sont en désaccord sur le nombre de décodages, l'écart entre eux est exploitable. Les matchers de routes, les middlewares et les handlers sont fréquemment en désaccord.

Évaluez une primitive de lecture de fichier selon ce que le processus peut atteindre, et non selon la primitive en elle-même. « Lecture arbitraire de fichier » évoque une divulgation d'informations. C'est devenu Critical parce que l'environnement était lisible, que le secret qu'il contenait signait des sessions, et que ces sessions n'expiraient jamais. Suivez la chaîne avant de lui attribuer un score.

Des jetons sans expiration transforment une fuite en compromission permanente. La divulgation d'une clé de signature avec des jetons à courte durée de vie est une mauvaise journée. Sans expiresIn, elle est irrécupérable sans faire tourner la clé — et la plupart des opérateurs ne sauront jamais qu'ils devaient le faire.

Exécutez le cas témoin. Vérifier qu'un jeton signé avec la mauvaise clé est rejeté est ce qui distingue une découverte démontrée d'une découverte supposée.

11. Références

  • Fiche CVE — https://www.cve.org/CVERecord?id=CVE-2026-19264
  • Avis de sécurité GitHub — https://github.com/gitroomhq/postiz-app/security/advisories/GHSA-4hgh-5rhf-4qpm
  • Avis CNA de Postiz (PSA-2026-TH12B7) — https://gadvisory.org/advisories/PSA-2026-TH12B7
  • Commit du correctif — https://github.com/gitroomhq/postiz-app/commit/7936062
  • Version corrigée v2.22.1 — https://github.com/gitroomhq/postiz-app/releases/tag/v2.22.1
  • CWE-22 — https://cwe.mitre.org/data/definitions/22.html

Recommandations de remédiation

Si vous auto-hébergez Postiz :

  1. Mettez à niveau vers la v2.22.1 ou une version ultérieure. C'est le correctif.
  2. Partez du principe que JWT_SECRET est compromise si vous avez exécuté une version affectée sur un hôte accessible publiquement avec STORAGE_PROVIDER=local. Faites-la tourner. Comme les jetons n'ont pas d'expiration, la rotation est le seul moyen d'invalider ceux qui ont pu être forgés.
  3. Faites tourner les identifiants DATABASE_URL et tous les secrets OAuth des fournisseurs connectés présents dans le même environnement.
  4. Vérifiez les journaux d'accès pour les requêtes GET vers /uploads/ contenant %2e ou %2f.

Recherche menée de manière indépendante et divulguée au fournisseur dans le cadre d'une divulgation coordonnée. Publiée après la diffusion du correctif et la publication de l'avis. Aucun système tiers n'a été consulté — toute la validation a été effectuée sur une instance locale construite à partir des sources du projet lui-même.


Licence

Cet article est sous licence CC BY 4.0 — partagez et adaptez librement avec attribution. Les extraits de code de gitroomhq/postiz-app sont cités pour l'analyse de sécurité et restent sous la licence de ce projet.

Krithik Babu P — @DarkLycn1976

Télécharger l’outil
12:18Correctif commité, vérifié et publié
2026-08-07 14:13CVE-2026-19264 assignée par Postiz (CNA)
2026-08-07 14:15Avis de sécurité GitHub publié