Skip to content

Unauthenticated remote process crash via malformed wire-protocol commands (missing bounds checks, no panic recovery)

Critical
mperham published GHSA-gc57-f6pg-m9h6 Aug 10, 2026

Package

gomod https://github.com/contribsys/faktory (Go)

Affected versions

<= 1.9.4

Patched versions

None

Description

Vulnerability Details

File: server/commands.go
Functions/Lines: queue() lines 51,69 — pushBulk() line 167 — push() line 212 — ack() line 268 — fail() line 292 — heartbeat() (BEAT) line 333
CWE: CWE-248 — Uncaught Exception (primary), CWE-617, CWE-400 (impact)
Severity: Critical
CVSS: 8.6 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (unauthenticated / no password configured); 7.1 with PR:L if a password is configured (any already-connected producer/worker still suffices)

Root Cause

Faktory'''s wire protocol is line-based: the server reads one line, looks up the verb in CommandSet, and calls the handler with the raw unparsed line. Several handlers slice the line at a fixed byte offset (e.g. cmd[5:]) or index into a fixed slice position (qs[0], qs[1]) WITHOUT checking that the line actually contains a payload. Sending the bare verb alone (no payload) causes a Go runtime panic (slice/index out of range).

Critically, there is no recover() anywhere in the entire codebase (verified via grep -rn "recover()" across all non-test source — zero results). An unrecovered panic in any goroutine terminates the ENTIRE Go process, not just that connection'''s goroutine. So a single malformed one-line command from any client crashes the whole server -- every other connection, every in-flight job, every worker, instantly disconnected.

This requires no special privilege beyond what any ordinary producer/consumer already has (or zero privilege at all if the operator hasn'''t configured a password, which is the project'''s own "development" environment default).

Attack Scenario

  1. Attacker connects to the Faktory command port (default 7419) and completes the trivial HELLO handshake (no credentials needed if unconfigured; the standard shared password otherwise).
  2. Attacker sends a single line containing ONLY a bare verb, e.g. PUSH , ACK , FAIL , BEAT , PUSHB , or QUEUE .
  3. The handler panics on the unguarded slice/index operation; nothing recovers it; the entire Faktory process exits.
  4. Every other client/worker on the instance is disconnected immediately; the whole job-processing pipeline is down until something restarts the process. The attacker can repeat this indefinitely to keep the service down.

Impact

Complete, 100%-reproducible, on-demand Denial of Service of the entire Faktory instance (not just the attacker'''s own connection) via a single short line of text, requiring no special privilege.

Vulnerable Code

func queue(c *Connection, s *Server, cmd string) {
	qs := strings.Split(cmd, " ")[1:]
	subcmd := strings.ToUpper(qs[0])   // panics if cmd == "QUEUE" (qs is empty)
	...
	if op != nil {
		if qs[1] == "*" {             // panics if cmd == "QUEUE PAUSE" (qs has only 1 element)

func push(c *Connection, s *Server, cmd string) {
	data := cmd[5:]   // panics if cmd == "PUSH" (len 4 < 5)

func ack(c *Connection, s *Server, cmd string) {
	data := cmd[4:]   // panics if cmd == "ACK" (len 3 < 4)

func fail(c *Connection, s *Server, cmd string) {
	data := cmd[5:]   // panics if cmd == "FAIL" (len 4 < 5)

func heartbeat(c *Connection, s *Server, cmd string) {
	data := cmd[5:]   // panics if cmd == "BEAT" (len 4 < 5)

func pushBulk(c *Connection, s *Server, cmd string) {
	data := cmd[6:]   // panics if cmd == "PUSHB" (len 5 < 6)

The dispatch loop in server/server.go (processLines) calls proc(conn, s, cmd) directly with no recover() wrapper anywhere.

Recommended Fix

  1. Add a recover() wrapper around command dispatch in processLines so a handler bug degrades to one client error/disconnect, not a full server crash:
func safeDispatch(proc command, conn *Connection, s *Server, cmd string) {
	defer func() {
		if r := recover(); r != nil {
			util.Error("panic handling command", fmt.Errorf("%v", r))
			_ = conn.Error(cmd, fmt.Errorf("internal error"))
		}
	}()
	proc(conn, s, cmd)
}
  1. Validate command shape before slicing/indexing in each handler, e.g.:
func push(c *Connection, s *Server, cmd string) {
	if len(cmd) < 6 || cmd[4] != ''' ''' {
		_ = c.Error(cmd, fmt.Errorf("invalid PUSH, missing payload"))
		return
	}
	data := cmd[5:]
	...
}

The same len(cmd) < N guard applies to pushBulk, ack, fail, heartbeat, and a len(qs) < N guard to queue.

Verification

Dynamically confirmed on v1.9.4, built from the official git tag, run locally (go1.26.4, embedded Redis v8.0.5). Six independent bare-verb commands (QUEUE, ACK, FAIL, PUSH, BEAT, PUSHB) were each sent on a freshly restarted instance; every single one produced a full process crash, confirmed via ps aux (process gone) and a captured Go panic stack trace pinpointing the exact unguarded slice/index operation in server/commands.go, propagating uncaught through server.(*Server).processLines with no recovery anywhere in the call chain.

Severity

Critical

CVE ID

CVE-2026-63403

Weaknesses

Uncaught Exception

An exception is thrown from a function, but it is not caught. Learn more on MITRE.