Research/vsftpd-deny-file-brace-child
0-dayNo CVEHighPublic

vsftpd brace deny_file misses children

vsftpd 3.0.5 man-page deny_file={*.mp3,*.mov,.private} denies CWD .private and song.mp3 but RETR .private/secret and dir/.private still serve. Labbed. Authenticated FTP privilege leftover.

Name
vsftpd brace deny_file misses children
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
vsftpd (Chris Evans)
Affected
Official vsftpd 3.0.5. Brace deny_file as documented. Default unset. Distro realpath patches (CVE-2015-1419) still match the full path.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, vsftpd, deny_file, privilege-leftover, cwe-863, cwe-22, remote

Matching names should not be accessible in any way

I am @abraxas_null. The proof of concept is on GitHub: abraxas/vsftpd-deny-file-brace-child (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, vsftpd.conf. Authorized lab only. It talks to loopback.

This is official vsftpd 3.0.5 from vsftpd-3.0.5.tar.gz, SHA-256 26b602ae454b0ba6d99ef44a09b6b9e0dfa7f67228106736df1f278c70bc91d3. VSF_VERSION "3.0.5". Not a GitHub mirror. Not a Debian or Fedora fork. No CVE yet. CWE-863 / CWE-22. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Remote. Authenticated FTP. Privilege leftover.

The man page for deny_file is not shy. Matching names "should not be accessible in any way." The list of any-ways includes download, change into directory, and "affect something within directory etc." The documented example is exactly deny_file={*.mp3,*.mov,.private}.

CWD .private is 550 Permission denied. RETR song.mp3 is 550. Then RETR .private/secret is 150 / 226 and the body is the witness. RETR dir/.private is the same 150 / 226 on a file whose last component is the denied name sitting under another directory.

The filter matched a last component when the last component was the whole argument. It did not match a child of a denied directory, and it did not match a denied basename that was not at index 0. The substring fallback searched for the literal brace string {*.mp3,*.mov,.private} and missed both paths. That is the leftover.

I did not read host /etc. The oracle is a witness string in a file the session uid can already Unix-open. FTP policy said no. str_open said yes.

Why vsftpd

I had just finished ProFTPD 1.3.9d. That leftover is contrib SITE COPY checking <Limit WRITE> on an unresolved dest, then pr_fs_copy_file2 following a planted symlink. vsftpd is the other classic C FTP daemon. Two-process. RootRevoke is not a knob here: the protocol child is the session uid after PASS, the parent helper only binds PASV and PORT-connects to the control peer. Sample conf turns anonymous on. Distros typically compile PAM and SSL. Stock builddefs.h has #undef VSF_BUILD_SSL and #define VSF_BUILD_PAM.

I only wanted Critical or High. Remote and Local. Medium stays on the floor unless it chains. DoS stays on the floor unless it is a write primitive. Anonymous-as-designed is out of bar. Unauthenticated banner / FEAT / SYST is recon. Firmware vsftpd.conf CVEs that only set chroot_local_user=YES are operator tunables of a different product.

Default compiled core FTP on this pin came back empty. The leftovers that survived are config-gated. This one is brace deny_file. Default is unset. The man page names it for virtual-user setups that share a guest uid, which is exactly when Unix DAC cannot separate the users and this tunable is the extra control.

Pinning official 3.0.5

Latest GA is still 3.0.5, August 2021. Chris Evans ships a tarball from security.appspot.com, not a GitHub release. GitHub copies exist. Distro trees exist. Those wait.

The pin is the official tarball. SHA-256 26b602ae454b0ba6d99ef44a09b6b9e0dfa7f67228106736df1f278c70bc91d3. vsftpver.h says VSF_VERSION "3.0.5". The lab image greps that string, greps #undef VSF_BUILD_SSL, and greps #define VSF_BUILD_PAM before make. Banner in the run is 220 (vsFTPd 3.0.5).

Debian and Fedora still quilt 3.0.5. Their patches are a different tree. CVE-2025-14242 is a Red Hat ls parser that is not in this tarball. CVE-2015-1419 is a public distro realpath / basename patch that official 3.0.5 never took. Pin the tarball so you do not "discover" a distro CVE with a press release.

The skip list is half the hunt

Before you look for leftovers, you write down what is already public so you do not file a CVE that already has a name.

  • CVE-2025-14242 Red Hat STAT integer overflow, Medium 6.5. Vulnerable code is a Fedora / RHEL [ range parser. Debian <not-affected>. Upstream ls.c has no such walk. Do not file it against this pin.
  • CVE-2015-1419 distro deny_file "not handled correctly." Public. SUSE / Debian / OpenWRT. The IBM report was deny_file=/home/* then CWD /./home/ after CWD /home/ 550. Distro patch: realpath, then basename match unless the pattern starts with { or /. Official 3.0.5 Changelog has no such fix. Do not re-file that exact alias. Debian's brace branch still copies the full normalized path, so the child walk in this post remains on patched distro packages too.
  • CVE-2021-3618 ALPACA. 3.0.4 already kills HTTP verbs, closes after ten unknown pre-login commands, rejects non-ftp ALPN, has ssl_sni_hostname.
  • CVE-2021-30047 connection-limit DoS. Availability.
  • CVE-2011-0762 vsf_filename_passes_filter CPU via STAT globs before 2.3.3. VSFTP_MATCHITERS_MAX 1000 is on this pin. Hitting the cap returns no-match. Not a deny bypass.
  • CVE-2011-2523 malicious 2.3.4 tarball backdoor. Not this SHA.
  • Firmware vsftpd.conf CVEs (TOTOLink, Linksys, TRENDnet, H3C, D-Link) that set chroot_local_user or anonymous upload. Operator conf, not this daemon leftover.
  • Documented-insecure tunables: pasv_promiscuous / port_promiscuous (FXP), allow_writeable_chroot, http_enable GET as anonymous FTP, one_process_model. Default off.
  • hide_file still RETR-able. Documented: "Despite being hidden, the files / directories etc. are fully accessible to clients who know what names to actually use."

3.0.x already closed the High classes this hunt was looking for on the default daemon: writeable-chroot deny (2.3.5+ / secutil.c), HTTP-verb kill, SSL-reuse-fail kill, seccomp on the dropped child, PORT foreign-IP reject, PASV accept matching the control peer.

Those stay on the skip list.

Nineteen surfaces, one deny leftover

I split the tree. FTP parser, PORT / PASV / FXP, SSL / TLS, seccomp / ptrace, privdrop / caps, chroot / path, PAM / virtual users, ls / STAT filter, post-login xfer, parseconf / user_config_dir, standalone listener, str / tunables / secbuf, Debian / Fedora patch delta, hide / deny / cmds_denied, logging / banner / HTTP, sysdeputil / sysutil, isolate / process model, ASCII / REST / SIZE, features / OPTS / hash.

Most of that is vsftpd doing what vsftpd said it would do.

Pre-login is USER / PASS / QUIT / FEAT / OPTS / AUTH / PBSZ / PROT. HTTP verbs kill the session. Ten unknown pre-login commands close. PORT foreign IP and ports < 1024 rejected unless port_promiscuous. PASV accept must match the control IP unless pasv_promiscuous. user_config_dir loads after PASS, ignores USER with /, wants a regular file owned by the daemon uid. ASCII SIZE stats S_ISREG and reports raw st_size. Upstream ls.c has no [ range loop. Seccomp on x86_64 has no execve / ptrace / mount / clone for the dropped child. Writeable chroot deny default on.

Default core FTP unpublished Crit / High: none.

Config-gated is where the Highs sat. Same pin, two other leftovers I am not mixing into this post: cmds_denied exact-token miss of RFC aliases (XRMD after RMD), and FTPS data ssl_accept treating any resumed ticket as the victim's control session. This write-up is brace deny_file children.

Then I opened vsf_access_check_file next to the man page example.

Brace deny_file is last-component plus a literal substring

vsf_access_check_file in access.c is the only deny gate. RETR, STOR, APPE, STOU, DELE, RNFR, RNTO, CWD, MKD, RMD, SIZE, MDTM, SITE CHMOD, and the LIST / NLST / STAT argument all call it on the client path after resolve_tilde. Default tunable_deny_file is NULL. When set:

C
  if (vsf_filename_passes_filter(p_filename_str, &s_access_str, &iters))
  {
    return 0;
  }
  else
  {
    struct str_locate_result loc_res =
      str_locate_str(p_filename_str, &s_access_str);
    if (loc_res.found)
    {
      return 0;
    }
  }
  return 1;

Filter match denies. Else a substring of the entire tunable string denies. There is no split on /. There is no split on ,. There is no "does any brace alternative appear as a path component."

vsf_filename_passes_filter (ls.c) is the custom matcher. No fnmatch. Comment in the function is the other half of the leftover:

C
  /* Currently supported pattern(s):
   * - any number of wildcards, "*" or "?"
   * - {,} syntax (not nested)
   *
   * Note that pattern matching is only supported within the last path
   * component. For example, searching for /a/b/? will work, but searching
   * for /a/?/c will not.
   */

Brace alternatives are expanded and matched at the start of the remaining name (must_match_at_current_pos = 1). Leftover bytes after a non-* token fail. A match that would start at index greater than 0 fails the current-position check.

Walk the documented example deny_file={*.mp3,*.mov,.private} against three names.

.private (CWD). Brace alts *.mp3, *.mov, .private. The third alt matches the whole argument. Leftover empty. Deny. 550. That is the man page working.

.private/secret (RETR). Same three alts, now against .private/secret. .private matches at index 0. Leftover is /secret. Last token is not *. Fail. *.mp3 and *.mov fail. Substring fallback searches for the literal {*.mp3,*.mov,.private} inside .private/secret and misses. Allow. handle_retr str_opens. 150 / 226.

dir/.private (RETR). Recursive filter call for alt .private against dir/.private finds .private at index 4. must_match_at_current_pos && indexx > 0 fails. Substring fallback still looks for the whole brace string. Allow. 150 / 226.

song.mp3 (RETR). Alt *.mp3: * then .mp3 with current-position relaxed after the star. Leftover empty. Deny. 550. The glob half of the example works. The directory half does not, once you put a slash in the argument.

Non-brace deny_file=.private is saved by the substring fallback. .private is a substring of .private/secret and of dir/.private. The man-page example used braces, so the fallback never sees .private as a needle. It sees {*.mp3,*.mov,.private}.

The same page has a copy-paste: "Access will be denied to items if their name contains the string given by hide_file." The C uses tunable_deny_file. The useful sentence is still the first one. Matching names should not be accessible in any way, including affecting something within the directory.

What I almost filed instead

CVE-2015-1419 as this leftover. /./ aliases and realpath / basename are public. Distros already shipped a patch. Official 3.0.5 never took it, which is a fact about the pin, not a new unmatched walk. The new walk is brace alternatives vs a child path, and vs a denied basename that is not the first component. Debian 0050 still matches brace patterns against the full normalized path, so RETR .private/secret remains on the patched package. Do not re-file 2015-1419. Do not treat "distros patched something nearby" as "this child is closed."

CVE-2025-14242 against upstream ls.c. The RH [ range parser is not in the tarball. Debian already said not-affected. Filing it here is a distro CVE with the wrong tree in the title.

hide_file RETR. LIST hides the dirent. RETR of a name you already know is the documented model. The hide matcher is the same last-component function. I almost wrote "hide also misses children" and then read the man page again. Hide is not deny. Drop.

cmds_denied RFC aliases as this post. XRMD after cmds_denied=RMD is a real leftover on the same pin. Different tunable, different gate (str_equal on the verb, not vsf_access_check_file). Not this write-up.

LIST of CWD showing .private. The man page already says the affected items are not hidden. Recon of a denied name in the listing is intended. LIST .private/x skipping the deny gate on the full argument is an amplifier of this leftover, not a third ID.

Inflating file read to RCE. The session uid can open a policy-denied path it can already Unix-open. No shell. No priv-parent. No chroot walk to host /etc. Class is privilege leftover.

Scoring it Medium because the man page says not serious ACL. The same page tells virtual-user operators this is the extra control. Shared guest_username means Unix DAC does not separate them. Authenticated RETR / STOR of a denied path is High when that tunable is the policy.

The lab

Official 3.0.5 built in Docker from the pinned tarball. Loopback 127.0.0.1:18710. PASV 18711-18718. Compose project vsftpd-deny-file-brace-child. Witness VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS. Image greps VSF_VERSION "3.0.5" before install. Anonymous read-only. write_enable=NO. deny_file is the man-page example.

lab/Dockerfile is the pin:

Dockerfile
# Official vsftpd 3.0.5. SHA-256 26b602ae454b0ba6d99ef44a09b6b9e0dfa7f67228106736df1f278c70bc91d3
# Stock builddefs.h: #undef VSF_BUILD_SSL. Do not install distro vsftpd.

RUN echo "26b602ae454b0ba6d99ef44a09b6b9e0dfa7f67228106736df1f278c70bc91d3  vsftpd-3.0.5.tar.gz" \
        | sha256sum -c - \
    && tar -xzf vsftpd-3.0.5.tar.gz \
    && test -f vsftpd-3.0.5/vsftpver.h \
    && grep -q 'VSF_VERSION "3.0.5"' vsftpd-3.0.5/vsftpver.h \
    && grep -q '#undef VSF_BUILD_SSL' vsftpd-3.0.5/builddefs.h \
    && grep -q '#define VSF_BUILD_PAM' vsftpd-3.0.5/builddefs.h \
    && sed -i 's/-Werror//g' vsftpd-3.0.5/Makefile \
    && make -C vsftpd-3.0.5 LIBS="-lpam -lcrypt -ldl" \
    && test -x vsftpd-3.0.5/vsftpd \
    && install -d /usr/local/sbin \
    && install -m 0755 vsftpd-3.0.5/vsftpd /usr/local/sbin/vsftpd \
    && /usr/local/sbin/vsftpd -v

The runtime image lays down /var/ftp/.private/secret with the witness, /var/ftp/dir/.private with DIRDOT- plus the same witness, and /var/ftp/song.mp3 with MP3-CLOSED-NOT-WITNESS. Jail is root-owned 755. Files are 0644. Unix DAC would allow the read. deny_file is supposed to be the gate.

lab/docker-compose.yml binds control and the PASV range to loopback:

YAML
    ports:
      - "127.0.0.1:18710:21"
      - "127.0.0.1:18711-18718:18711-18718"

lab/vsftpd.conf: listen=YES, anonymous_enable=YES, local_enable=NO, write_enable=NO, deny_file={*.mp3,*.mov,.private}, pasv_address=127.0.0.1, pasv_min_port=18711, pasv_max_port=18718. port_enable=NO. Seccomp and isolate are off in the lab so Docker on macOS can publish the PASV range 1:1. That is lab scaffolding, not the leftover.

Control, then the denied basename, then the child, then the dotted file, then the glob that still works:

  1. Banner 220 (vsFTPd 3.0.5). USER ftp / PASS x@x -> 230. TYPE I -> 200.
  2. CWD .private -> 550 Permission denied.
  3. PASV on 18713. RETR .private/secret -> 150 Opening BINARY mode data connection for .private/secret (37 bytes). then 226 Transfer complete. Body VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS.
  4. PASV on 18714. RETR dir/.private -> 150 / 226, 44 bytes, body DIRDOT-VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS.
  5. PASV on 18716. RETR song.mp3 -> 550 Permission denied. No body. The closed marker never left the jail.

Replay:

Plain text
git clone https://github.com/abraxas/vsftpd-deny-file-brace-child
cd vsftpd-deny-file-brace-child/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it.

Plain text
banner 220 220 (vsFTPd 3.0.5)
login 230 anonymous ftp
TYPE I 200 200 Switching to Binary mode.
CWD .private 550 550 Permission denied.
PASV 227 port=18713 227 Entering Passive Mode (127,0,0,1,73,25).
RETR .private/secret mark=150 150 Opening BINARY mode data connection for .private/secret (37 bytes).
RETR .private/secret done=226 bytes=37 226 Transfer complete.
retr-child mark=150 done=226 has-witness=1 body=VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS\n
PASV 227 port=18714 227 Entering Passive Mode (127,0,0,1,73,26).
RETR dir/.private mark=150 150 Opening BINARY mode data connection for dir/.private (44 bytes).
RETR dir/.private done=226 bytes=44 226 Transfer complete.
retr-dir-dotprivate mark=150 done=226 has-witness=1 body=DIRDOT-VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS\n
PASV 227 port=18716 227 Entering Passive Mode (127,0,0,1,73,28).
RETR song.mp3 mark=550 550 Permission denied.
retr-song.mp3 mark=550 done=550 body=
SUCCESS vsftpd-deny-file-brace-child cwd-dotprivate=550 retr-child=yes retr-dir-dotprivate=yes mp3=550 VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS

Wrong turns, in the order I made them

Pinning a GitHub mirror. vsftpd's upstream is a tarball on appspot. A GitHub copy of 3.0.5 is a convenience. A Debian source package is a fork with 0050 already applied. The leftover has to be shown on the official SHA, then noted as still present on the distro brace branch.

Filing CVE-2015-1419 again. /./home/ after deny_file=/home/* is the public report. realpath plus basename is the public patch. Brace children are a different unmatched walk that the public patch did not close. The skip list is there so you do not collapse those two sentences.

Filing CVE-2025-14242 against this pin. I read handle_dir_common. Options are ftp_arg_str[0] == '-', then str_mid_to_end and str_split_char. No C while (isspace(*p++)). No length-plus-one wrap. The RH parser is not here.

Treating hide_file RETR as the bug. I spent a morning confirming LIST hides .private and RETR still serves it. Then I read vsftpd.conf.5 hide_file. Intended. The leftover is deny, and only when the operator copied the brace example.

Docker PASV on macOS without pasv_address=127.0.0.1. vsftpd advertises its container IP. The published range is on the host loopback. The data connect hangs, and a hung RETR looks like a deny. The lab publishes 18711-18718 1:1 and forces pasv_address=127.0.0.1 so the 227 tuple is something the client can open. Isolate-network on would have put PASV in a netns the publish mapping does not share.

Writeable chroot for a STOR lab. Default allow_writeable_chroot is 0. vsftpd dies with 500 OOPS: vsftpd: refusing to run with writable root inside chroot() if you make /var/ftp writeable so anonymous can STOR. The read leftover does not need that. STOR / DELE / RNTO of the same child path go through the same vsf_access_check_file gate; they want write_enable and a jail vsftpd will actually start. I left them inferred and kept the jail root-owned 755.

Taking CWD .private 550 as "deny works." Basename hits the brace. That is the control, not the finding. The PoC fails closed if that CWD is not 550, and fails closed if song.mp3 is not 550. Otherwise you have proven vsftpd is not running the example conf.

A pasteable RETR .private/secret against someone else's :21. Theatre. The missing per-component brace match is the useful part. If you own the daemon, run the proof of concept against loopback.

What an attacker can do

Needs an authenticated FTP login (anonymous when the operator enabled it, or a local / guest user). deny_file set to a brace list. Default unset, so stock 3.0.5 with the compiled defaults is not this leftover. Virtual-user recipes the man page names remap everyone to guest_username (default ftp), so Unix DAC does not separate them and this tunable is the extra ACL. Victim path must be Unix-openable by the session uid, inside the existing chroot. No unauth. No shell. No priv-parent. No host /etc from a jail.

Privilege Method / sink Lab-proven or inferred
Confidentiality: read a file under a denied directory RETR .private/secret with documented brace deny_file. handle_retr vsf_access_check_file on the client arg, then str_open Lab-proven. 150 / 226, 37 bytes, body VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS. CWD .private stayed 550
Confidentiality: read a file whose basename is on the deny list but is not the first path component RETR dir/.private. Same gate. Brace alt .private rejected at indexx > 0 Lab-proven. 150 / 226, body DIRDOT-VSFTPD-DENY-FILE-BRACE-CHILD-WITNESS
Integrity: create / overwrite / unlink / rename the same paths STOR / APPE / STOU / DELE / RNTO -> handle_upload_common / str_unlink / rename. Same vsf_access_check_file Inferred. Same helper, lab was write_enable=NO
Integrity: mkdir / rmdir a denied child MKD / RMD of .private/x Inferred. Same helper (postlogin.c 1183 / 1220)
Recon: SIZE / MDTM / SITE CHMOD of a denied child those handlers call the same check, then stat / chmod Inferred. SIZE would report the child. CHMOD needs chmod_enable (default YES; anonymous already cannot SITE)
Recon: list inside a denied tree LIST .private/x. handle_dir_common access-checks the full arg (miss), then str_opendir of the .private prefix after the slash split. ls_recurse_enable=YES plus LIST -R also opendirs denied dirents with no second deny check Inferred. Default ls_recurse_enable=0. LIST of CWD still shows denied names; that part is documented
Negative: CWD .private basename hits brace alt .private Lab-proven. 550 Permission denied
Negative: RETR song.mp3 brace alt *.mp3 Lab-proven. 550. Closed marker never served
Negative: non-brace deny_file=.private substring fallback finds .private in .private/secret and in dir/.private Closed by the fallback. Not labbed in this run. Why the documented braces matter
Negative: files the session uid cannot Unix-open str_open still needs DAC Closed. FTP leftover, not a uid change
Negative: hide_file={.private} RETR documented intended Closed as a finding. Different tunable
Negative: host /etc from a chroot kernel chroot Closed. This leftover is inside the jail
Negative: unauthenticated HTTP GET http_enable default off; FTP-mode GET kills the session. HTTP path still calls handle_retr and deny_file Closed
Negative: unauthenticated RETR pre-login table has no RETR Closed. 530
Negative: CVE-2015-1419 /./ alias as this ID public distro class Skip as public. Brace child walk remains on that patch
Negative: CVE-2025-14242 STAT overflow RH parser absent Closed on this pin
RCE inside vsftpd none claimed Closed. No payload. Class is privilege leftover

cmds_allowed is fail-closed for unlisted verbs and is not this leftover. download_enable=NO still blocks RETR even when deny_file missed. write_enable=NO still blocks the write family. Those family switches are independent of the glob.

The fix

Match brace alternatives on every path component, not only against the client string as a last-component pattern. Split the argument on /. Run the existing filter against each component (and against the final basename). Keep the last-component match for patterns like *.mp3. Change the substring fallback so a brace list searches for each alternative, not for the literal {*.mp3,*.mov,.private}.

Debian 0050 is not that fix. After realpath, non-brace patterns match the basename and close /./ aliases. Brace patterns still receive the full normalized path, so .private/secret still fails the last-component match and still misses the substring needle.

Until then: do not copy the man-page brace example if deny_file is the extra virtual-user ACL. Non-brace deny_file=.private is saved by substring. Unix DAC still applies and is what the same man page already recommended for serious policy. cmds_allowed is the fail-closed lock-down, not deny_file.

I am not going to print a RETR .private/secret you can paste at someone else's :21. The missing per-component brace match is the useful part. If you own the daemon, run the proof of concept against loopback.

References

vsftpd brace deny_file misses children · Abraxas Labs