Hi,
I'm writing to report a security vulnerability in GNU inetutils ftpd,
affecting all versions through 2.7.
The myoob() SIGURG handler in ftpd/ftpd.c calls multiple functions that are
not async-signal-safe, including stdio operations, syslog(), free(), and
longjmp(). When a remote attacker sends TCP urgent data (the standard FTP
ABOR sequence per RFC 959) during an active file transfer, the longjmp()
can abandon glibc's _int_free() while the arena mutex is held, permanently
deadlocking the child process.
On systems with glibc < 2.26 (no tcache), the same primitive corrupts
freelist metadata, potentially enabling arbitrary code execution. Combined
with the seteuid() privilege model (saved set-UID remains 0), this could
yield remote root.
I've attached a full writeup with root cause analysis, affected code
locations.
I'd like to propose a 30-day disclosure window from today's date. I'm happy
to work with you on a fix - the writeup includes suggested remediation
approaches (replacing signal/longjmp with a flag-based or self-pipe
approach).
I also plan to request a CVE ID from MITRE and coordinate with the distros
list once a patch is ready. Please let me know if you'd prefer a different
timeline or process.
Please confirm receipt when you can.
Best regards,
Oculytic team
# Heap Corruption via Async-Signal-Unsafe SIGURG Handler in GNU inetutils ftpd
## Summary
GNU inetutils ftpd through version 2.7 contains a signal handler race condition
in the `myoob()` function (`ftpd/ftpd.c`) that leads to heap corruption and
denial of service. A remote, unauthenticated attacker can send TCP urgent (OOB)
data on the FTP control connection to trigger `SIGURG` during heap operations in
the transfer path. The signal handler calls `longjmp()`, which can abandon
glibc's `_int_free()` while the arena mutex is held, permanently deadlocking the
child process. On older glibc versions (< 2.26), the same primitive corrupts
freelist metadata, potentially enabling arbitrary code execution as root.
- **Affected software:** GNU inetutils ftpd <= 2.7
- **Attack vector:** Remote, unauthenticated (anonymous FTP or any valid account)
- **Impact:** Denial of service (permanent child deadlock); potential remote code
execution on systems with glibc < 2.26
- **Root cause:** CWE-364 (Signal Handler Race Condition), CWE-479 (Signal
Handler Use of a Non-Reentrant Function)
## Background
The FTP protocol (RFC 959) uses TCP urgent data to deliver out-of-band commands
(`ABOR`, `STAT`) during active file transfers. The ftpd server registers
`myoob()` as the handler for `SIGURG`, which the kernel delivers when urgent
data arrives on the control socket. The control socket has `SO_OOBINLINE`
enabled, so the urgent byte is placed in the normal data stream and read by
`telnet_fgets()` inside the handler.
## Vulnerability Details
### The Signal Handler
The `myoob()` handler (`ftpd.c:1915`) calls multiple functions that are not
async-signal-safe:
```c
static void
myoob (int signo MAYBE_UNUSED)
{
char *cp;
if (!transflag)
return;
cp = tmpline;
if (telnet_fgets (cp, 7, stdin) == NULL) /* stdio: not safe */
{
reply (221, "You could at least say goodbye.");
dologout (0); /* free(): not safe */
}
upper (cp);
if (strcmp (cp, "ABOR\r\n") == 0)
{
tmpline[0] = '\0';
reply (426, "Transfer aborted. ..."); /* printf/syslog: not safe */
reply (226, "Abort successful");
longjmp (urgcatch, 1); /* abandons caller state */
}
if (strcmp (cp, "STAT\r\n") == 0)
{
reply (213, "Status: %s bytes transferred",
off_to_str (byte_count)); /* printf/syslog: not safe */
}
}
```
The unsafe operations include:
| Call | Unsafe because |
|------|----------------|
| `telnet_fgets()` → `getc(stdin)` | stdio re-entrancy; may corrupt `FILE` internal state |
| `reply()` → `printf()`/`fflush()` | stdio re-entrancy on `stdout` |
| `reply()` → `syslog()` | may call `malloc()` internally |
| `dologout()` → `end_login()` → `free()` | heap re-entrancy |
| `longjmp(urgcatch, 1)` | abandons caller's stack frame and any held locks |
### The Heap Corruption Primitive
During a `STOR` (file upload) in binary mode, `receive_data()` allocates a
buffer via `malloc()` and frees it after the transfer loop:
```c
static int
receive_data (FILE *instr, FILE *outstr, off_t blksize)
{
char *buf;
transflag++;
if (setjmp (urgcatch))
{
transflag = 0;
return -1; /* longjmp lands here */
}
/* TYPE I / TYPE L */
buf = malloc ((unsigned int) blksize); /* typically 4096 bytes */
while ((cnt = read (fileno (instr), buf, blksize)) > 0)
{
if (write (fileno (outstr), buf, cnt) != cnt)
{
free (buf);
goto file_err;
}
byte_count += cnt;
}
free (buf); /* <-- SIGURG can fire here */
if (cnt < 0)
goto data_err;
transflag = 0; /* <-- still 1 during free() above */
return 0;
}
```
The critical window is at `free(buf)` (line 1514). At this point:
1. `transflag` is still nonzero (set to 0 two lines later)
2. glibc's `_int_free()` has acquired the arena mutex and is manipulating bin
metadata
If `SIGURG` is delivered during `_int_free()` — via a timer interrupt or
inter-processor interrupt — the handler runs. Because `transflag` is still set,
the handler processes the command. If the command is `ABOR`, the handler calls
`longjmp(urgcatch, 1)`, which:
- Abandons `_int_free()` mid-operation
- Leaves the arena mutex permanently held
- Leaves bin pointers in a partially-updated state
Control returns to the `setjmp` in `receive_data()`, which returns `-1` to
`store()`. The caller then executes `fclose(din)` (line 1137), which internally
calls `free()` on the `FILE` object's 8192-byte I/O buffer. This second `free()`
attempts to acquire the arena mutex:
- **glibc >= 2.3 (NPTL):** `lll_lock` on the already-held mutex →
`futex_wait` → **permanent deadlock**
- **glibc < 2.26 (no tcache):** mutex may not deadlock for single-threaded
processes, but the corrupted bin metadata is accessed → **crash or
write-what-where**
### The Memory Leak Primitive (mmap path)
`send_data()` uses `mmap()` for files under 8 MB. The mapping is only released
by `munmap()` at the end of the transfer. A `longjmp` from the handler skips
`munmap()`, leaking the mapping. On 32-bit systems, repeated exploitation
exhausts the 3 GB virtual address space in hundreds of iterations, crashing the
child.
### Privilege Escalation Context
The ftpd child drops privileges with `seteuid()`, not `setuid()`. The saved
set-user-ID remains 0. Any code execution in the child can reclaim root:
```c
seteuid(0); /* saved set-UID is root */
execve("/bin/sh", ...);
```
## Triggering the Bug
The attacker sends TCP urgent data (the standard FTP `ABOR` sequence per RFC
959) on the control connection while a file transfer is active:
1. Authenticate (anonymous or any valid account)
2. Set `TYPE I` (binary mode)
3. Issue `PASV`, connect data socket
4. Issue `STOR <filename>`, receive `150`
5. Close data socket (zero-byte upload) → server calls `read()` → returns 0 →
`free(buf)`
6. Send `IAC IP` + `IAC DM` (with `MSG_OOB`) + `ABOR\r\n` on the control
connection → kernel generates `SIGURG`
If the `SIGURG` is delivered during `free(buf)` — which requires a timer
interrupt or IPI to interrupt the userspace `_int_free()` call — the `longjmp`
abandons the heap operation.
The probability per attempt is approximately:
| Configuration | `free()` duration | P(hit per cycle) |
|---------------|-------------------|------------------|
| Default build, 1000 Hz kernel | ~1 μs | ~0.1% |
| Default build, 250 Hz kernel | ~1 μs | ~0.025% |
| `MALLOC_CHECK_=3` | ~3–5 μs | ~0.1–0.5% |
| ASan build (`-fsanitize=address`) | ~10 μs | ~1% |
| Local `kill()` bombardment (multi-core) | ~1 μs | ~20% |
## Impact
### Denial of Service (all glibc versions)
Each successful trigger permanently deadlocks (or crashes) one ftpd child
process. The child enters an unrecoverable `futex_wait` on the arena mutex. On
`inetd`-based deployments, repeated exploitation exhausts the process table or
connection limits.
Confirmed by attaching to the child process after triggering:
```
futex_wait
__lll_lock_wait
malloc ← blocked acquiring abandoned arena mutex
receive_data
store
```
### Heap Metadata Corruption (glibc < 2.26)
On systems without tcache (glibc < 2.26, common on 32-bit and older
distributions), the arena mutex handling differs for single-threaded processes.
The `fclose()` → `free()` after the `longjmp` accesses the corrupted unsorted
bin, following partially-updated `fd`/`bk` pointers. This gives an attacker a
write primitive exploitable via standard techniques (unsorted bin attack,
`__free_hook` overwrite).
Combined with the `seteuid(0)` privilege model, successful exploitation yields
**remote root**.
### Virtual Address Space Exhaustion (32-bit, mmap path)
Repeated `RETR` + `ABOR` against `mmap`-ed files leaks ~4–8 MB of virtual
address space per cycle. On 32-bit systems (3 GB user VAS), ~400–768 cycles
exhaust the address space, crashing the child.
## Affected Code
| File | Function | Line | Issue |
|------|----------|------|-------|
| `ftpd/ftpd.c` | `myoob()` | 1915–1945 | Signal handler calls non-async-signal-safe functions |
| `ftpd/ftpd.c` | `myoob()` | 1935 | `longjmp()` from signal context abandons held locks |
| `ftpd/ftpd.c` | `receive_data()` | 1514 | `free(buf)` while `transflag` is still set |
| `ftpd/ftpd.c` | `receive_data()` | 1487–1491 | `setjmp`/`longjmp` used for transfer abort |
| `ftpd/ftpd.c` | `send_data()` | 1316–1474 | `longjmp` skips `munmap()` cleanup |
| `ftpd/ftpd.c` | `store()` | 1137 | `fclose(din)` after `receive_data` error deadlocks on arena mutex |
## Suggested Fix
Replace the `signal()`-based `SIGURG` handler with a self-pipe or
`signalfd()`-based approach that defers OOB command processing to the main event
loop. At minimum:
1. Remove `longjmp()` from the signal handler. Use a `volatile sig_atomic_t`
flag instead, checked in the transfer loop.
2. Do not call `telnet_fgets()`, `reply()`, `syslog()`, or any stdio/heap
function from signal context.
3. Consider using `sigaction()` with `SA_RESTART` and masking `SIGURG` during
`malloc`/`free` critical sections.