# Root Cause Analysis: CVE-2026-35025

## Summary

CVE-2026-35025 is a post-authentication access-control bypass in ProFTPD. An authenticated FTP user can evade a `<Directory>` block configured with `DenyAll` by prefixing the absolute path in an `RNFR` command with `/proc/self/root`. Because ProFTPD's `dir_canonical_path()` cleans the path lexically without resolving the `/proc/self/root` symlink, the resulting canonical path still contains the prefix. `dir_check()` then performs a lexical match against configured `<Directory>` blocks; the `/proc/self/root`-prefixed path does not match the intended protected-directory block, so the `DenyAll` policy is skipped. The attacker can then `RNTO` the protected file into an allowed directory and `RETR` it. Configurations using `DefaultRoot`/`chroot` are not affected because `/proc/self/root` resolves relative to the chroot.

## Impact

- **Package/component affected:** ProFTPD FTP server (`modules/mod_core.c` RNFR handler, `src/support.c` `dir_canonical_path()`, `src/dirtree.c` `dir_check()`/`dir_match_path()`).
- **Affected versions:** ProFTPD through 1.3.9b and 1.3.10rc2 (issue #2170).
- **Risk level:** High (CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N, score 8.1; CVSS 4.0 8.6).
- **Consequences:** Authenticated users can bypass directory-level ACLs to rename and read files located in paths that should be protected by `DenyAll`.

## Impact Parity

- **Disclosed/claimed maximum impact:** Access-control bypass allowing file rename and retrieval from DenyAll-protected directories.
- **Reproduced impact from this run:** Confirmed authenticated FTP access-control bypass: direct `RETR` and `RNFR` to `protected/secret.txt` are denied with `550 Operation not permitted`, but `RNFR /proc/self/root/<absolute>/protected/secret.txt` returns `350`, `RNTO` to the public directory succeeds with `250 Rename successful`, and the secret content is subsequently retrieved via `RETR public/leaked.txt`.
- **Parity:** `full`.
- **Not demonstrated:** No remote code execution or crash was claimed; the claim was limited to ACL bypass and file leakage, which was fully demonstrated.

## Root Cause

In the `RNFR` command handler (`modules/mod_core.c` around `core_rnto`/RNFR logic), the supplied path is cleaned with `dir_canonical_path()`:

```c
abs_path = dir_abs_path(cmd->tmp_pool, path, FALSE);
path = dir_canonical_path(cmd->tmp_pool, path);
```

`dir_canonical_path()` (`src/support.c`) concatenates the path with the current working directory and then calls `pr_fs_clean_path2()`, which performs only lexical normalization (removing `.`, `..`, duplicate `/`, etc.). It does **not** call `readlink()` or otherwise resolve symlink components. Therefore a path such as:

```
/proc/self/root/data/.../protected/secret.txt
```

remains literally that string after canonicalization. On a non-chrooted Linux server, `/proc/self/root` is a symlink to the real root, so the path ultimately resolves to the same file as `/data/.../protected/secret.txt`, but the canonical string retains the prefix.

Next, the handler calls `dir_check()` (`src/dirtree.c`) to enforce `<Directory>`/`Limit` policies. `dir_check()` relies on `dir_match_path()` to locate the matching `<Directory>` block. `dir_match_path()` does a prefix/string comparison of the canonical path against configured directory names. The configured protected block (e.g., `/data/.../protected`) does not match the `/proc/self/root/.../protected/secret.txt` string, so no `DenyAll` block is found and the operation is allowed. The file is then accepted as a valid rename source and stored in `session.xfer.path`, after which `RNTO` and `RETR` operate on the file normally.

- **Fix commit:** Not yet available in the upstream repository at the time of this run (issue #2170 remains open, CVE published 2026-06-24). A robust fix would resolve symlink components before the directory-policy check, or teach `dir_check()`/`dir_match_path()` to compare the real filesystem path rather than the lexical canonical string.

## Reproduction Steps

1. Run `bundle/repro/reproduction_steps.sh`.
2. The script checks out ProFTPD `v1.3.9b` from the project cache, builds it with minimal modules, and starts the server on `localhost:2121` as the current user.
3. It creates an `AuthUserFile` virtual user `testuser`/`testpass` and a directory layout with `protected/secret.txt` (DenyAll) and `public/` (AllowAll). No `DefaultRoot`/`chroot` is configured.
4. The Python FTP client connects, logs in, and shows that direct `RETR protected/secret.txt` and `RNFR protected/secret.txt` are denied with `550 Operation not permitted`.
5. The client then sends `RNFR /proc/self/root/<absolute>/protected/secret.txt`, receives `350 File or directory exists, ready for destination name`, and `RNTO` the file into the public directory, receiving `250 Rename successful`.
6. Finally, the client `RETR public/leaked.txt` and the original secret content is returned.

Expected evidence:
- `bundle/logs/reproduction_steps.log` shows the full build and exploit output.
- `bundle/logs/proftpd.log` shows the server starting and the configured DenyAll/AllowAll directory blocks.
- `bundle/repro/artifacts/ftp_exploit_output.txt` contains the FTP command responses, including `direct_retr: DENIED`, `direct_rnfr: DENIED`, `bypass_rnfr: ALLOWED`, `bypass_rnto: ALLOWED`, `retr_public: ALLOWED`, and `SUCCESS: Secret content was leaked...`.
- `bundle/repro/ftp-root/public/leaked.txt` contains the secret content that was originally in `bundle/repro/ftp-root/protected/secret.txt`.

## Evidence

- `bundle/logs/reproduction_steps.log`: full build and exploit trace.
- `bundle/logs/proftpd.log`: ProFTPD daemon logs confirming the vulnerable configuration, e.g.:
  ```
  <Directory /.../protected>: adding section for resolved path '/.../protected'
  Limit DenyAll
  ```
  and the absence of a match for the `/proc/self/root`-prefixed path.
- `bundle/repro/artifacts/ftp_exploit_output.txt`:
  ```
  Direct RETR denied: 550 protected/secret.txt: Operation not permitted
  Direct RNFR denied: 550 protected/secret.txt: Operation not permitted
  RNFR response: 350 File or directory exists, ready for destination name
  RNTO response: 250 Rename successful
  RETR line: This is the secret content that should not be accessible via normal FTP.
  SUCCESS: Secret content was leaked via RNFR /proc/self/root bypass!
  ```
- Environment: Ubuntu container, ProFTPD v1.3.9b (`390b21555`), no `DefaultRoot`/`chroot`.

## Recommendations / Next Steps

- **Fix approach:** In `dir_canonical_path()` or in the RNFR handler, resolve symlink components via `realpath()`/`readlink()` before the directory ACL check, so that `/proc/self/root/...` collapses to the real absolute path and matches the configured `<Directory>` block. Alternatively, make `dir_match_path()`/`dir_check()` compare the physical filesystem path rather than the lexical string. Care must be taken to preserve ProFTPD's chroot-aware semantics.
- **Upgrade guidance:** Apply the upstream fix once released; until then, avoid relying solely on `<Directory>` `DenyAll` for sensitive paths, and consider using `DefaultRoot`/`chroot`, which the advisory notes is not affected.
- **Testing recommendations:** Add regression tests that send `RNFR` with `/proc/self/root`, `/proc/self/cwd`, and other procfs symlink prefixes against protected directories, and verify they are denied.

## Additional Notes

- **Idempotency:** The reproduction script was executed twice consecutively and produced the same successful result both times.
- **Edge cases/limitations:** The proof intentionally disables `DefaultRoot`/`chroot` because the vulnerability is documented as not affecting chrooted sessions. The reproduction is a non-privileged server running on port 2121; it demonstrates the same code path as a production server. The exploit requires valid FTP credentials (post-authentication).
