# Root Cause Analysis: CVE-2026-35025 DELE Bypass Variant

## Summary

The disclosed CVE-2026-35025 vulnerability is an authenticated FTP access-control bypass in ProFTPD: an attacker can prefix an absolute path with `/proc/self/root` in the `RNFR` command so that `dir_canonical_path()` leaves the symlink prefix in place, and the subsequent lexical `dir_check()` fails to match the configured `DenyAll` `<Directory>` block. This variant shows that the same root cause is reachable through a different FTP command: `DELE`. The `core_dele()` handler uses the same `dir_canonical_path()` + `dir_check()` pattern, so a `/proc/self/root`-prefixed path bypasses the directory ACL and deletes a protected file. We also tested the upstream/proposed one-line fix (changing `core_rnfr()` to use `dir_check_canon()`) and confirmed that `DELE` still bypasses the patch.

## Fix Coverage / Assumptions

The fix proposed in the upstream issue discussion (`proftpd/proftpd#2170`) changes only one line in `modules/mod_core.c`:

```c
-      !dir_check(cmd->tmp_pool, cmd, cmd->group, path, NULL) ||
+      !dir_check_canon(cmd->tmp_pool, cmd, cmd->group, path, NULL) ||
```

inside `core_rnfr()`. It relies on the assumption that `RNFR` is the only command that feeds the output of `dir_canonical_path()` into `dir_check()`. It explicitly covers the `RNFR` command path: `core_rnfr()` -> `dir_canonical_path()` -> `dir_check_canon()` -> `dir_best_path()` -> `dir_check()`.

What the fix does **not** cover:
- `core_dele()` (`DELE` command), which uses the same `dir_canonical_path()` + `dir_check()` pattern.
- Other commands in `modules/mod_core.c` and `modules/mod_facts.c` that call `dir_canonical_path()` followed by `dir_check()` (e.g., `MDTM`, `SIZE`, `MFMT`, `MLST` fact handlers).

## Variant / Alternate Trigger

- **Entry point:** authenticated FTP `DELE` command.
- **Attacker-controlled input:** absolute path argument prefixed with `/proc/self/root`, e.g., `DELE /proc/self/root/ftp-root/protected/secret.txt`.
- **Code path:**
  - `modules/mod_core.c:core_dele()`
  - `src/support.c:dir_canonical_path()` (lexical canonicalization, leaves `/proc/self/root`)
  - `src/dirtree.c:dir_check()` (lexical `<Directory>` match misses the protected block)
  - `src/fsio.c:pr_fsio_unlink()` (deletes the file at the resolved real path)
- **Difference from parent claim:** the parent claim uses `RNFR`/`RNTO` to rename and retrieve a protected file; this variant uses `DELE` to delete a protected file, and it bypasses the proposed `RNFR`-only fix.

## Impact

- **Package/component affected:** ProFTPD FTP server (`modules/mod_core.c:core_dele()`, `src/support.c:dir_canonical_path()`, `src/dirtree.c:dir_check()`).
- **Affected versions (as tested):** ProFTPD v1.3.9b (commit `390b21555`) and the same commit with the proposed `RNFR`-only patch applied.
- **Risk level:** High (same CVSS family as the parent CVE; post-authentication ACL bypass with integrity impact on protected files).
- **Consequences:** An authenticated FTP user can delete files inside directories protected by `DenyAll` `<Directory>`/`<Limit>` blocks.

## Impact Parity

- **Disclosed/claimed maximum impact:** Access-control bypass allowing file rename and retrieval from `DenyAll`-protected directories.
- **Reproduced impact from this variant run:** Access-control bypass allowing **deletion** of a file inside a `DenyAll`-protected directory using the `DELE` command with a `/proc/self/root`-prefixed path. The same variant also bypasses the proposed `RNFR`-only fix.
- **Parity:** `partial` — the root cause and bypass mechanism are the same, but the demonstrated impact is file deletion rather than rename/retrieval.
- **Not demonstrated:** reading the contents of a protected file via `DELE` (deletion does not provide read access), remote code execution, or privilege escalation beyond the authenticated user.

## Root Cause

The root cause is identical to CVE-2026-35025: `dir_canonical_path()` in `src/support.c` performs only lexical path cleaning (collapsing `.` and `..`, removing duplicate slashes) without resolving symbolic links. When a path such as `/proc/self/root/ftp-root/protected/secret.txt` is passed to `dir_canonical_path()`, the result still contains the `/proc/self/root` prefix. `dir_check()` then matches this string against `<Directory>` configuration blocks; because the configured block is `/ftp-root/protected` and not `/proc/self/root/ftp-root/protected`, the `DenyAll` policy is skipped. The filesystem operation is performed on the real path because `/proc/self/root` resolves to the process root at the kernel level.

The proposed fix resolves symlinks for `RNFR` by calling `dir_check_canon()`, which internally uses `dir_best_path()` (a `realpath()`-style resolver). But `DELE` was left unchanged, so the same bypass is still possible after the patch.

## Reproduction Steps

1. Run `bundle/vuln_variant/reproduction_steps.sh`.
2. The script builds the test environment and starts two ProFTPD instances:
   - Vulnerable ProFTPD v1.3.9b on port 2121.
   - Patched ProFTPD v1.3.9b (with the proposed `RNFR` fix) on port 2122.
3. For the vulnerable server, it confirms both the original `RNFR` exploit and the `DELE` variant succeed.
4. For the patched server, it confirms the `RNFR` exploit is blocked but the `DELE` variant still succeeds.
5. Exit code is `0` when the bypass is confirmed.

Expected evidence:
- `bundle/logs/variant_reproduction_steps.log` shows the sequence of server starts and client results.
- `bundle/logs/proftpd_vuln_variant.log` and `bundle/logs/proftpd_patched_variant.log` contain ProFTPD debug output.
- The vulnerable client output shows `vuln_rnfr: ALLOWED` and `vuln_dele: ALLOWED`.
- The patched client output shows `patched_rnfr: DENIED` and `patched_dele: ALLOWED`.

## Evidence

- `bundle/logs/variant_reproduction_steps.log` — full console output from both reproduction runs.
- `bundle/logs/proftpd_vuln_variant.log` — vulnerable server logs.
- `bundle/logs/proftpd_patched_variant.log` — patched server logs.
- `bundle/vuln_variant/test_dele_variant.sh` — early standalone test of the `DELE` variant on the vulnerable binary.
- `bundle/vuln_variant/test_dele_patched.sh` — early standalone test of the `DELE` variant against the patched binary.
- Local patched source worktree: `/data/pruva/project-cache/e16fa440-7670-4503-8601-378cf2096f7e/repo-patched` with the diff applied to `modules/mod_core.c`.

Key excerpt from the final reproduction:
```
[ PATCHED ] RNFR exploit (should be blocked)
RNFR denied: 550 /proc/self/root/.../protected/secret.txt: Operation not permitted
[ PATCHED ] DELE variant (should still bypass)
DELE allowed
BYPASS CONFIRMED: proposed RNFR fix leaves DELE path open
```

Environment details:
- Host: Linux container, x86_64
- User: `vscode` (uid 1000, gid 1000)
- ProFTPD built from source at v1.3.9b (commit `390b21555`)
- Patched build: same commit, plus one-line change `dir_check()` -> `dir_check_canon()` in `core_rnfr()`

## Recommendations / Next Steps

1. **Extend the fix to `DELE`:** change `core_dele()` in `modules/mod_core.c` to use `dir_check_canon()` after `dir_canonical_path()`.
2. **Audit all `dir_canonical_path()` + `dir_check()` sites:** specifically review `modules/mod_core.c` (other symlink-aware commands) and `modules/mod_facts.c` (`MDTM`, `SIZE`, `MFMT`, `MLST`) to ensure none are left vulnerable to the same `/proc/self/root` prefix trick.
3. **Add regression tests:** test `RNFR`, `DELE`, and any other affected commands with `/proc/self/root`-prefixed paths on both non-chrooted and chrooted configurations.
4. **Consider a central fix:** either make `dir_canonical_path()` symlink-aware for ACL purposes, or consistently use `dir_check_canon()` whenever the canonicalized path is used for permission enforcement.

## Additional Notes

- The script was run twice successfully and produced the same results, confirming idempotency.
- The vulnerable repository checkout and the patched worktree were left in their original states; no mutation was performed on the source used by the repro stage (`/data/pruva/project-cache/e16fa440-7670-4503-8601-378cf2096f7e/repo`).
- Upstream has not yet released a fix; the patched version tested here is the likely minimal fix discussed in the public issue.
