# Patch Analysis: CVE-2026-35025 ProFTPD /proc/self/root ACL Bypass

## 1. What the upstream/proposed fix changes

The public discussion on GitHub issue `proftpd/proftpd#2170` proposes a minimal one-line fix in `modules/mod_core.c` inside the `core_rnfr()` FTP `RNFR` handler:

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

The change replaces the lexical `dir_check()` call with `dir_check_canon()`, which first calls `dir_best_path()` (a `realpath()`-style resolver) on the path before matching it against configured `<Directory>` blocks. With `dir_check_canon()`, a path such as `/proc/self/root/ftp-root/protected/secret.txt` is resolved to `/ftp-root/protected/secret.txt` before the `<Directory>` ACL is evaluated, so the `DenyAll` block for the protected directory matches and the `RNFR` is rejected.

No upstream release containing this fix exists yet in the mirrored repository (the issue is still open as of 2026-07-01). For this variant stage we applied the proposed patch to a detached worktree at commit `390b21555` (v1.3.9b) and rebuilt ProFTPD to test the bypass hypothesis.

## 2. What the fix assumes

- The only command that reaches the vulnerable sink (`dir_canonical_path()` output passed to `dir_check()`) is `RNFR`.
- Resolving symlinks in the path before ACL checks is sufficient to close the attack surface.
- Other FTP commands already use `dir_check_canon()` or otherwise do not share the same `dir_canonical_path() + dir_check()` pattern.
- The documented workaround (`DefaultRoot` / chroot) remains acceptable for deployments that do not adopt the patch.

## 3. What the fix does NOT cover

The one-line `RNFR`-only fix leaves at least one materially different command untouched: `DELE` in `modules/mod_core.c:core_dele()` uses the exact same pattern:

```c
  /* If told to delete a symlink, don't delete the file it points to!  */
  path = dir_canonical_path(cmd->tmp_pool, path);
  ...
  if (!dir_check(cmd->tmp_pool, cmd, cmd->group, path, NULL)) {
```

Because `core_dele()` still calls `dir_check()` with the lexically-canonicalized path, prefixing the victim path with `/proc/self/root` still causes the `<Directory>` match to miss the protected directory. The actual `pr_fsio_unlink()` then deletes the file at the resolved real path.

Other code paths that also use `dir_canonical_path()` followed by `dir_check()` (rather than `dir_check_canon()`) should be reviewed as part of a complete fix. A code audit shows this pattern in `modules/mod_core.c` for other commands and in `modules/mod_facts.c` for fact-listing/modification commands such as `MDTM`, `SIZE`, `MFMT`, and `MLST`. Each of those would need to be confirmed or ruled out with runtime tests; the `DELE` path is the clearest, highest-impact bypass demonstrated here.

## 4. Fix completeness assessment

The proposed `RNFR`-only fix is **incomplete**. It blocks the originally disclosed exploit vector (rename via `RNFR`/`RNTO`) but does not remove the underlying weakness: `dir_canonical_path()` does not resolve symlinks, and several commands feed that output directly into `dir_check()`, which performs lexical `<Directory>` matching. A complete fix must either:

1. Change `dir_canonical_path()` to resolve symlink prefixes (or reject procfs-style symlink prefixes), or
2. Audit and update every command that uses `dir_canonical_path() + dir_check()` to use `dir_check_canon()` (or an equivalent realpath-aware check), including `DELE` and the fact-modification commands.

Option 2 is the least risky in terms of preserving symlink-rename semantics, but it is easy to miss a command. Option 1 is more central but may have portability concerns (procfs is not universal), which is why the maintainer expressed reluctance to add procfs-specific code.

## 5. Behavior comparison before and after the proposed fix

| Command | Vulnerable v1.3.9b | Patched v1.3.9b (RNFR-only fix) |
|---------|-------------------|----------------------------------|
| `RNFR /proc/self/root/.../protected/secret.txt` | `350` allowed | `550 Operation not permitted` |
| `DELE /proc/self/root/.../protected/secret.txt` | `250` allowed | `250` still allowed (bypass) |
| `RNFR /proc/self/root/.../protected/secret.txt` followed by `RNTO ...` | rename succeeds | rename blocked |

This runtime evidence confirms that the proposed one-line fix closes the disclosed `RNFR` vector but the same `/proc/self/root` prefix still defeats ACL checks for `DELE`.

## 6. Target threat model / security policy notes

ProFTPD does not publish a `SECURITY.md` in the repository root. The only threat-model guidance comes from the issue discussion and existing documentation:

- The `DefaultRoot` (chroot) configuration is documented as not affected because `/proc/self/root` resolves relative to the chroot.
- The maintainer suggests `PathDenyFilter ^/proc/` as a configuration-level workaround.
- The maintainer states that renaming symlinks is intentional behavior and that the issue is not severe enough to warrant a code change at this time.

Despite that maintainer opinion, the CVE is published with a High/CVSS 8.6 rating and treats the behavior as an access-control bypass. The variant documented here stays within the same trust boundary (authenticated FTP user) and the same root cause (lexical canonicalization before ACL checks), so it is a valid bypass of the proposed, incomplete fix.

## 7. Recommendation for the coding stage

The coding-stage fix should not be limited to `core_rnfr()`. At minimum, `core_dele()` must also be changed from `dir_check()` to `dir_check_canon()` after its `dir_canonical_path()` call. A more robust fix would audit every `dir_canonical_path()` followed by `dir_check()` site and either:
- switch them to `dir_check_canon()`, or
- make `dir_canonical_path()` itself symlink-aware when the result is intended for ACL enforcement.

Additionally, regression tests should cover `DELE`, `RNFR`, and the fact-modification commands with `/proc/self/root`-prefixed paths.
