
CVE-2026-19264 - Postiz (< 2.22.1) में गंभीर अनप्रमाणित पाथ ट्रैवर्सल से पूर्ण इंस्टेंस टेकओवर। तकनीकी विवरण: डिकोड-ऑर्डर बायपास, JWT_SECRET एस्केलेशन, और अपस्ट्रीम फिक्स का विश्लेषण।

लेखक: कृथिक बाबू पी (@DarkLycn1976)
प्रकाशित: 2026-08-10
CVE: CVE-2026-19264
गंभीरता: क्रिटिकल - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - प्रतिबंधित निर्देशिका में पाथनाम की अनुचित सीमा
प्रभावित: gitroomhq/postiz-app < 2.22.1
सुधारित संस्करण: v2.22.1
Postiz स्थानीय रूप से संग्रहीत मीडिया को एक रूट के माध्यम से सर्व करता था जो URL-आपूर्ति किए गए पाथ सेगमेंट्स को अपलोड निर्देशिका के साथ जोड़ता था और परिणाम को वापस स्ट्रीम करता था - बिना किसी पाथ नॉर्मलाइज़ेशन, बिना किसी कंटेनमेंट जाँच, और बिना किसी प्रमाणीकरण के।
स्पष्ट ट्रैवर्सल पेलोड 404 लौटाता है, क्योंकि Next.js रूटिंग से सेगमेंट्स को समाप्त कर देता है। लेकिन और फ़ाइलसिस्टम कॉल के रास्ते में ठीक एक बार और डीकोड किए जाते हैं, हर जाँच के दूसरी ओर ट्रैवर्सल को पुनर्स्थापित करते हुए।
../एक अनप्रमाणित हमलावर एप्लिकेशन प्रोसेस द्वारा पठनीय किसी भी फ़ाइल को पढ़ सकता था - जिसमें उसका अपना एनवायरनमेंट भी शामिल है, जिसमें JWT साइनिंग सीक्रेट होता है। क्योंकि Postiz उस सीक्रेट के साथ सत्र टोकन पर हस्ताक्षर करता है और उन्हें बिना किसी समाप्ति (expiry) क्लेम के जारी करता है, इसे प्राप्त करना एक फ़ाइल-रीड प्रिमिटिव को किसी भी उपयोगकर्ता के रूप में स्थायी, जाली बनाने योग्य सत्र में बदल देता है, जिसमें एक व्यवस्थापक भी शामिल है।
पूर्ण इंस्टेंस टेकओवर के लिए केवल एक अनप्रमाणित GET अनुरोध।
Postiz एक ओपन-सोर्स सोशल मीडिया शेड्यूलिंग प्लेटफ़ॉर्म है - लिखने के समय लगभग 34,000 GitHub स्टार्स - जो Next.js फ्रंटएंड और NestJS बैकएंड के रूप में बनाया गया है। यह कनेक्टेड सोशल अकाउंट्स, शेड्यूल किए गए कंटेंट और बिलिंग को प्रबंधित करने के लिए एजेंसियों और छोटी टीमों द्वारा व्यापक रूप से सेल्फ-होस्ट किया जाता है।
सेल्फ-होस्टेड डिप्लॉयमेंट अपलोड किए गए मीडिया को ऑब्जेक्ट स्टोरेज के बजाय स्थानीय रूप से संग्रहीत कर सकते हैं। यह व्यवहार एक ही एनवायरनमेंट वेरिएबल द्वारा नियंत्रित होता है:
STORAGE_PROVIDER=local
यही वह मान है जो .env.example में भेजा जाता है, इसलिए जब तक वे जानबूझकर S3 या Cloudflare R2 कॉन्फ़िगर नहीं करते, अधिकांश सेल्फ-होस्टर यही चलाते हैं।
जब स्थानीय स्टोरेज सक्रिय होता है, next.config.js सार्वजनिक पाथ /uploads/:path* को एक आंतरिक API रूट पर रीराइट करता है:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
कोई बग शामिल होने से पहले ही दो गुण इस रूट को दिलचस्प बनाते हैं:
[[...path]] वैकल्पिक कैच-ऑल सेगमेंट का अर्थ है कि शेष हर पाथ घटक एक ऐरे के रूप में आता है जिसे हैंडलर अपने अनुसार व्याख्या करने के लिए स्वतंत्र है।जब STORAGE_PROVIDER local के अलावा कुछ भी होता है, तो रीराइट /404 की ओर इंगित करता है और हैंडलर अगम्य हो जाता है। वह कॉन्फ़िगरेशन गेट ही एकमात्र चीज़ है जो किसी डिप्लॉयमेंट और इस बग के बीच खड़ी है।
v2.22.1 से पहले का हैंडलर:
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
};
चार पंक्तियों में तीन दोष:
path.normalize(), path.resolve() - इनमें से कोई भी कॉल नहीं किया जाता। जो भी सेगमेंट आते हैं, वे यथावत जोड़ दिए जाते हैं।filePath अभी भी UPLOAD_DIRECTORY के अंदर है।+ '/' + घटकों को टेक्स्ट के रूप में मानता है, न कि अर्थ-सहित पाथ के रूप में।परिणाम सीधे createReadStream() में जाता है और बाइट्स को फ़ाइलनाम से अनुमानित MIME प्रकार के साथ कॉलर को स्ट्रीम किया जाता है। एक्सटेंशन की कोई अनुमति-सूची (allow-list) नहीं है और कोई कंटेंट फ़िल्टर नहीं है।
पाठ्यपुस्तकीय (textbook) हमला है:
GET /uploads/../../../etc/passwd
Postiz पर यह 404 लौटाता है, और यही 404 पूरा कारण है कि यह बग मिलने तक जीवित रहा।
Next.js रूटिंग के दौरान अनुरोध पाथ को नॉर्मलाइज़ करता है। रूटर यह तय करने से पहले कि किस हैंडलर को आमंत्रित किया जाए, कच्चे ../ सेगमेंट्स को समाप्त कर दिया जाता है। जब तक अनुरोध कैच-ऑल तक पहुँचता है, ट्रैवर्सल पहले ही समाप्त किया जा चुका होता है - या तो पाथ कहीं ऐसी जगह रिज़ॉल्व होता है जहाँ कोई मेल खाने वाला रूट नहीं होता, या यह डॉट-सेगमेंट्स के बिना वापस /uploads के अंदर रिज़ॉल्व होता है।
जल्दी से परीक्षण करने वाले किसी व्यक्ति के लिए, वह 404 "फ्रेमवर्क इसे संभाल लेता है" जैसा पढ़ा जाता है। यह एक वास्तविक, कार्यशील सुरक्षा है। समस्या यह नहीं है कि यह अनुपस्थित है - समस्या यह है कि यह पाइपलाइन में कहाँ चलता है।
रूट मैचिंग और अनुरोध हैंडलर समान संख्या में पर्सेंट-डीकोडिंग पास नहीं करते।
यदि सेपरेटर पर्सेंट-एन्कोडेड हैं, तो अनुक्रम रूट मैचिंग के दौरान पाथ सेपरेटर नहीं होता। %2e%2e%2f केवल एक अपारदर्शी स्ट्रिंग है - निष्क्रिय टेक्स्ट जिसे नॉर्मलाइज़र छूने का कोई कारण नहीं रखता। यह रूटिंग से सुरक्षित गुज़रता है, कैच-ऑल द्वारा मैच किया जाता है, और हैंडलर के params में जाते समय डीकोड हो जाता है, जहाँ यह फिर से ../ बन जाता है।
उस बिंदु पर इसे UPLOAD_DIRECTORY के साथ जोड़ दिया जाता है और createReadStream() को सौंप दिया जाता है - रूटिंग से परे, नॉर्मलाइज़ेशन से परे, हर उस नियंत्रण से परे जो इसे रोक सकता था।
कार्यशील रूप:
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
डबल-एन्कोडिंग काम नहीं करती - %252e एकल डीकोड पास के माध्यम से शाब्दिक बना रहता है और कभी डॉट नहीं बनता। ठीक एक परत एन्कोडिंग सबसे उपयुक्त है, जो एक उपयोगी अनुस्मारक है कि "और कठिन एन्कोड करो" कोई रणनीति नहीं है।
जिस सिद्धांत (invariant) को धारण करना है:
डीकोडिंग पूर्ण होने से पहले चलने वाला नियंत्रण सिंक (sink) की सुरक्षा नहीं कर रहा है।
फ़ाइल-रीड प्रिमिटिव अपने आप में High है। इसे Critical बनाने वाली चीज़ है कि यह किस तक पहुँचता है।
चरण 1 - एनवायरनमेंट पढ़ें। Node प्रोसेस का अपना कॉन्फ़िगरेशन डिप्लॉयमेंट रूट में डिस्क पर होता है। .env अन्य चीज़ों के अलावा यह देता है:
JWT_SECRET - सत्र टोकन हस्ताक्षर कुंजीDATABASE_URL - पूर्ण Postgres क्रेडेंशियल्सचरण 2 - सत्र जाली बनाएँ। Postiz jsonwebtoken के माध्यम से HS256 का उपयोग करके JWT_SECRET के साथ सत्र टोकन पर हस्ताक्षर करता है। महत्वपूर्ण रूप से, टोकन बिना किसी expiresIn के जारी किए जाते हैं, इसलिए एक जाली टोकन अनिश्चित काल तक मान्य रहता है।
चरण 3 - कोई भी बन जाएँ। प्रमाणीकरण मिडलवेयर id क्लेम का उपयोग करके डेटाबेस से उपयोगकर्ता को पुनः-रिज़ॉल्व करता है। यह जानबूझकर टोकन से isSuperAdmin जैसे क्लेम पर भरोसा नहीं करता - अच्छा डिज़ाइन - लेकिन एक बार जब आप किसी भी id पर हस्ताक्षर कर सकते हैं तो वह सख्ती अप्रासंगिक हो जाती है। { id: <victim user id> } पर हस्ताक्षर करने से एक ऐसा सत्र उत्पन्न होता है जो वैध लॉगिन से अप्रभेद्य होता है:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
मैंने इसे वास्तविक jsonwebtoken डिपेंडेंसी के साथ प्रोजेक्ट के वास्तविक सत्यापन तर्क के विरुद्ध मान्य किया: पुनर्प्राप्त सीक्रेट के साथ हस्ताक्षरित टोकन स्वीकार किया गया, और गलत सीक्रेट के साथ हस्ताक्षरित वही टोकन अस्वीकार कर दिया गया। नियंत्रण मामला (control case) मायने रखता है - इसके बिना आपके पास एक खोज नहीं, बल्कि एक धारणा होती है।
चरण 4 - समानांतर पथ। अकेला DATABASE_URL सीधे Postgres पहुँच के लिए पर्याप्त है: हर कनेक्टेड अकाउंट पढ़ें, या सीधे किसी व्यवस्थापक फ़्लैग को बदल दें।
कोई पासवर्ड नहीं। कोई पूर्व पहुँच नहीं। कोई उपयोगकर्ता सहभागिता नहीं। केवल एक अनप्रमाणित HTTP अनुरोध।
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 और UI:N वे दो मेट्रिक्स हैं जो काम कर रहे हैं। रूट को किसी सत्र और किसी पीड़ित सहभागिता की आवश्यकता नहीं होती - हमलावर अकेले, नेटवर्क पर, एक डिफ़ॉल्ट कॉन्फ़िगरेशन के विरुद्ध कार्य करता है।
एकमात्र ईमानदार सीमाकारक कॉन्फ़िगरेशन गेट है: S3 या R2 पर डिप्लॉयमेंट उजागर नहीं होते, क्योंकि रूट /404 पर रीराइट हो जाता है। यह प्रभावित आबादी को कम करता है लेकिन उसके भीतर किसी के लिए गंभीरता को नहीं - और local ही भेजा गया डिफ़ॉल्ट है।
अनुरक्षकों का पैच (7936062) आठ पंक्तियों का है और पढ़ने योग्य है, क्योंकि यह उस तरह से सही है जिस तरह से ये सुधार अक्सर नहीं होते:
+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 });
+ }
दो चीज़ें जो यह सही करता है:
resolve() डीकोडिंग पूर्ण होने के बाद .. को समाप्त कर देता है, इसलिए इससे कोई फर्क नहीं पड़ता कि ट्रैवर्सल को रूटिंग के माध्यम से कैसे तस्करी करके लाया गया था। जाँच अब वहीं बैठती है जहाँ खतरा है।base के विरुद्ध नहीं, बल्कि base + sep के विरुद्ध तुलना करता है। एक भोला filePath.startsWith(base) /app/uploads-evil/x को /app/uploads के अंदर होने के रूप में स्वीकार कर लेता - एक क्लासिक प्रीफ़िक्स-मैच बाइपास। सेपरेटर जोड़ने से यह बंद हो जाता है, और filePath !== base खंड निर्देशिका को स्वयं मान्य रखता है।कंटेनमेंट जाँच के लिए यही सही आकार है: रिज़ॉल्व करें, फिर अनुगामी सेपरेटर के साथ बेस के विरुद्ध तुलना करें।
सभी समय UTC, 2026-07-20, जब तक अन्यथा उल्लेख न किया गया हो।
| समय | घटना |
|---|---|
| 05:55 | Postiz टीम को सलाह रिपोर्ट की गई |
| 07:44 | अनुरक्षकों द्वारा स्वीकार और सत्यापित की गई |
| 12:18 | सुधार कमिट, सत्यापित और प्रकाशित किया गया |
| 2026-08-07 14:13 | CVE-2026-19264 Postiz (CNA) द्वारा निर्धारित |
| 2026-08-07 14:15 | GitHub सुरक्षा सलाह प्रकाशित |
रिपोर्ट से लेकर भेजे गए पैच तक छह घंटे और तेईस मिनट, एक ओपन-सोर्स प्रोजेक्ट पर जिससे कोई बग बाउंटी जुड़ी नहीं है। मैंने समर्पित सुरक्षा टीमों वाले संगठनों में रिपोर्टों को महीनों तक अछूते पड़े देखा है। समन्वय के लिए Enno Gelhaus और सुधार के लिए Nevo David को श्रेय।
आपके परीक्षण से गुज़रने वाला नियंत्रण यह नहीं दर्शाता कि नियंत्रण सही जगह पर है। 404 वास्तविक था। Next.js वास्तव में ../ को समाप्त करता है। बचाव बस इनपुट के डीकोड होने से पहले चला, जिसका अर्थ था कि यह फ़ाइलसिस्टम कॉल के बजाय रूटर की रक्षा कर रहा था। जब आप कोई शमन (mitigation) पाते हैं, तो पूछें कि सिंक के सापेक्ष यह कब निष्पादित होता है - न कि केवल यह कि क्या यह मौजूद है।
एन्कोडिंग एक परत है, और परतें अलग-अलग दरों पर उतारी जाती हैं। जब भी अनुरोध पाइपलाइन में दो घटक इस बात पर असहमत होते हैं कि कितनी बार डीकोड करना है, उनके बीच का अंतराल शोषणीय होता है। रूट मैचर, मिडलवेयर और हैंडलर अक्सर असहमत होते हैं।
फ़ाइल-रीड प्रिमिटिव को इस आधार पर वर्गीकृत करें कि प्रोसेस किस तक पहुँच सकता है, न कि प्रिमिटिव के आधार पर। "Arbitrary file read" सूचना प्रकटीकरण जैसा लगता है। यह Critical बन गया क्योंकि एनवायरनमेंट पठनीय था, उसमें मौजूद सीक्रेट सत्रों पर हस्ताक्षर करता था, और वे सत्र कभी समाप्त नहीं होते थे। इसे स्कोर करने से पहले श्रृंखला का अनुसरण करें।
गैर-समाप्त होने वाले टोकन एक रिसाव को स्थायी समझौते में बदल देते हैं। अल्पकालिक टोकन के साथ हस्ताक्षर कुंजी का प्रकटीकरण एक बुरा दिन है। बिना expiresIn के, यह सीक्रेट को घुमाए बिना पुनर्प्राप्ति योग्य नहीं है - और अधिकांश ऑपरेटरों को कभी पता भी नहीं चलेगा कि उन्हें ऐसा करने की आवश्यकता थी।
नियंत्रण मामला चलाएँ। यह सत्यापित करना कि गलत सीक्रेट के साथ हस्ताक्षरित टोकन अस्वीकार कर दिया जाता है, वही चीज़ है जो एक प्रदर्शित खोज को एक मान ली गई खोज से अलग करती है।
यदि आप Postiz को सेल्फ-होस्ट करते हैं:
JWT_SECRET समझौता हो चुका है यदि आपने STORAGE_PROVIDER=local के साथ सार्वजनिक रूप से सुलभ होस्ट पर प्रभावित संस्करण चलाया है। इसे घुमाएँ। क्योंकि टोकनों में कोई समाप्ति नहीं होती, जो भी जाली बनाए गए हों उन्हें अमान्य करने का एकमात्र तरीका घुमाना है।DATABASE_URL क्रेडेंशियल्स और उसी एनवायरनमेंट में रखे गए किसी भी कनेक्टेड-प्रोवाइडर OAuth सीक्रेट्स को घुमाएँ।/uploads/ पर %2e या %2f युक्त GET अनुरोधों के लिए एक्सेस लॉग जाँचें।शोध स्वतंत्र रूप से किया गया और समन्वित प्रकटीकरण के तहत विक्रेता को सूचित किया गया। सुधार भेजे जाने और सलाह सार्वजनिक होने के बाद प्रकाशित किया गया। किसी तृतीय-पक्ष प्रणाली तक पहुँच नहीं की गई - सभी सत्यापन प्रोजेक्ट के स्वयं के स्रोत से निर्मित एक स्थानीय इंस्टेंस के विरुद्ध किया गया।
यह लेख CC BY 4.0 के अंतर्गत लाइसेंस प्राप्त है - श्रेय के साथ स्वतंत्र रूप से साझा करें और अनुकूलित करें। gitroomhq/postiz-app के कोड अंश सुरक्षा विश्लेषण के लिए उद्धृत किए गए हैं और उस प्रोजेक्ट के लाइसेंस के अंतर्गत बने हुए हैं।
कृथिक बाबू पी - @DarkLycn1976