kern_ktrace.c: ktrpsig() leaks 4 bytes of uninitialised stack via struct ktr_psig padding
Stuart Thomas <[email protected]> Wed, 13 May 2026 10:22:06 +0100
| Newsgroups | gmane.os.openbsd.bugs |
|---|---|
| Message-ID | <CACS-ydN3Wrvj7pp35GnNkmndFDWjypr4HW1-=mvg2+LmFkigRw@mail.gmail.com> |
Hi,
In sys/kern/kern_ktrace.c, ktrpsig() stack-allocates a struct ktr_psig
and assigns its fields individually, but does not zero the struct first.
The struct contains a 4-byte alignment hole at offset 4 (between
"int signo" and "sig_t action"), which is then written verbatim to the
ktrace output file via ktrwrite().
This is the same class as the April 2026 fixes for shm_internal
(1b900a0, deraadt) and sem_base (76d3556, dgl) -- uninitialised
kernel-stack data exposed to userland through a same-UID syscall path.
Source (current master, sys/kern/kern_ktrace.c line 283):
void
ktrpsig(struct proc *p, int sig, sig_t action, int mask, int code,
siginfo_t *si)
{
struct ktr_header kth;
struct ktr_psig kp; /* not memset before field assignments */
atomic_setbits_int(&p->p_flag, P_INKTR);
ktrinitheader(&kth, p, KTR_PSIG);
kp.signo = (char)sig;
kp.action = action;
kp.mask = mask;
kp.code = code;
kp.si = *si;
KERNEL_LOCK();
ktrwrite(p, &kth, &kp, sizeof(kp));
KERNEL_UNLOCK();
atomic_clearbits_int(&p->p_flag, P_INKTR);
}
struct ktr_psig layout (sys/sys/ktrace.h):
int signo; /* offset 0 */
/* offset 4: 4-byte padding hole (sig_t
alignment) */
sig_t action; /* offset 8 */
int mask; /* offset 16 */
int code; /* offset 20 */
siginfo_t si; /* offset 24 */
The padding hole is never assigned anywhere in the function.
Tested on OpenBSD 7.7 arm64 (GENERIC.MP #361, 22 Apr 2025) in a UTM VM.
A PoC that traces its own process with KTRFAC_PSIG and raises 32 signals
interleaved with various other syscalls produces 32 PSIG records of
which 13 (40%) carry the value 0xffffff80 in the padding hole -- the
upper 32 bits of an arm64 kernel virtual address (TTBR1 region prefix).
The remaining 19 (59%) carry 0x00000000, consistent with the slot being
overwritten by zero by an intervening kernel function on those code
paths.
Verbatim hexdump of a leaking record:
00000080 1e 00 00 00 80 ff ff ff c0 14 9e 95 01 00 00 00
signo=30 PADDING action=0x1959e14c0
SIGUSR1 LEAKED
0xffffff80
I am not claiming this is an exploitable KASLR weakening -- on arm64
the 0xffffff80 prefix is constant across the architecture, so it does
not disclose anything secret. But the structurally-equivalent reference
fixes were treated as worth correcting on hygiene grounds, and I think
the same applies here: the kernel should not be writing uninitialised
stack contents into a user-readable file, even on an opt-in trace
interface. On a different stack history or a different architecture
(amd64 not tested in this session) the leaked bytes could plausibly
carry less innocuous data.
Suggested fix:
--- sys/kern/kern_ktrace.c
+++ sys/kern/kern_ktrace.c
@@ -285,6 +285,7 @@ ktrpsig(struct proc *p, int sig, sig_t action,
struct ktr_header kth;
struct ktr_psig kp;
+ memset(&kp, 0, sizeof(kp));
atomic_setbits_int(&p->p_flag, P_INKTR);
ktrinitheader(&kth, p, KTR_PSIG);
kp.signo = (char)sig;
PoC sources attached:
- POC_ktr_psig_padding.c: minimal reproducer, 16 PSIG records
- POC_ktr_entropy.c: extended PoC, 32 records across 4 signal types
with interleaved syscalls, showing the 40%/59% distribution
Both build with: cc -O0 -o poc poc.c
Honestly noted:
- amd64 not tested by me; padding rules are arch-agnostic on LP64 so
the layout will be the same, but the values landing in the slot may
differ.
- The bug is opt-in (ktrace must be enabled) and same-UID.
- The constant 0xffffff80 prefix is not by itself a KASLR defeat.
Reporting this primarily as a hygiene/consistency fix matching
1b900a0 and 76d3556.
Thanks,
Stuart Thomas
--
*please note, there is no expectation for you to read/reply to my email
outside your normal working hours. *
POC_ktr_psig_padding.c
(application/octet-stream, 3.4 KB)
/*
* POC: ktr_psig stack-allocated struct in kern_ktrace.c is not memset
* before fields are individually assigned. On amd64/arm64 the layout is:
* offset 0 int signo (4 bytes)
* offset 4 PADDING HOLE (4 bytes, alignment for sig_t)
* offset 8 sig_t action (8 bytes)
* offset 16 int mask (4 bytes)
* offset 20 int code (4 bytes)
* offset 24 siginfo_t si
*
* Hypothesis: the 4-byte hole at offset 4 contains uninitialized kernel
* stack data, observable by the tracing process (which can be the same
* UID as the traced process per ktrcanset()).
*
* Procedure:
* 1) ktrace -f outfile -t p ./self (PSIG trace)
* 2) self installs SIGUSR1 handler, raises SIGUSR1
* 3) parse outfile, locate KTR_PSIG record, dump bytes 4..7
* 4) repeat N times; non-deterministic non-zero → uninit leak
*/
#include <sys/types.h>
#include <sys/ktrace.h>
#include <sys/uio.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
static void handler(int s) { (void)s; }
int main(void)
{
/* Child traces self, parent will inspect the file */
/* Pre-create the output file (ktrace(2) requires it to exist) */
int ofd = open("/tmp/ktrace.out", O_CREAT|O_RDWR|O_TRUNC, 0644);
if (ofd < 0) { perror("open(create)"); return 1; }
close(ofd);
pid_t pid = fork();
if (pid == 0) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigaction(SIGUSR1, &sa, NULL);
/* Enable ktrace on self for PSIG only */
if (ktrace("/tmp/ktrace.out", KTROP_SET,
KTRFAC_PSIG, getpid()) == -1) {
perror("ktrace");
_exit(1);
}
for (int i = 0; i < 16; i++) {
raise(SIGUSR1);
}
ktrace("/tmp/ktrace.out", KTROP_CLEAR, KTRFAC_PSIG, getpid());
_exit(0);
}
int status; waitpid(pid, &status, 0);
int fd = open("/tmp/ktrace.out", O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
unsigned char buf[8192];
ssize_t n = read(fd, buf, sizeof(buf));
close(fd);
printf("ktrace.out size: %zd bytes\n", n);
/* Walk records. struct ktr_header ends with size_t ktr_len.
* Record layout: header || payload(ktr_len).
* Header size depends on _MAXCOMLEN — we scan for KTR_PSIG type (0x05). */
/* Simpler: dump hex of any payload immediately following type==KTR_PSIG */
/* But ktrace file format requires real parsing. Use kdump for now and
* also dump raw 32-byte windows showing the offset-4 padding hole. */
int found = 0;
for (ssize_t i = 0; i + 64 < n; i++) {
/* Heuristic: locate ktr_psig payload by scanning for a known
* action-handler pointer (handler symbol) preceded by 4 bytes of
* arbitrary padding. */
uint64_t cand;
memcpy(&cand, buf + i + 8, 8);
uint32_t signo;
memcpy(&signo, buf + i, 4);
if (signo == SIGUSR1 && cand > 0x1000 && cand < 0x800000000000ULL) {
uint32_t pad;
memcpy(&pad, buf + i + 4, 4);
printf("PSIG@%zd: signo=%u PAD=0x%08x action=0x%lx\n",
i, signo, pad, (unsigned long)cand);
found++;
}
}
if (!found) printf("No PSIG records matched heuristic. Run kdump manually.\n");
return 0;
}
POC_ktr_entropy.c
(application/octet-stream, 2.7 KB)
/*
* ktr_entropy.c — force kernel-stack variance between PSIG records.
* Tests Council/ChatGPT's high-value condition: does the padding hole
* carry different values across invocations when interleaved with
* other syscalls?
*/
#include <sys/types.h>
#include <sys/ktrace.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
static void noop(int s) { (void)s; }
int
main(void)
{
pid_t pid = getpid();
unlink("/tmp/ktr_entropy.out");
int fd = open("/tmp/ktr_entropy.out", O_CREAT|O_WRONLY|O_TRUNC, 0600);
if (fd < 0) { perror("open"); return 1; }
close(fd);
if (ktrace("/tmp/ktr_entropy.out", KTROP_SET,
KTRFAC_PSIG, pid) < 0) { perror("ktrace"); return 1; }
signal(SIGUSR1, noop);
signal(SIGUSR2, noop);
signal(SIGHUP, noop);
signal(SIGURG, noop);
/* 32 iterations, each preceded by a DIFFERENT syscall path to
* vary what's left on the kernel stack before sendsig() runs. */
int sigs[] = { SIGUSR1, SIGUSR2, SIGHUP, SIGURG };
for (int i = 0; i < 32; i++) {
switch (i % 8) {
case 0: { struct stat st; stat("/etc/passwd", &st); break; }
case 1: { int s = socket(AF_UNIX, SOCK_STREAM, 0); close(s); break; }
case 2: { getppid(); getpgrp(); getsid(0); break; }
case 3: { char buf[64]; getcwd(buf, sizeof buf); break; }
case 4: { int f = open("/dev/null", O_RDONLY);
if (f >= 0) { read(f, (char[16]){0}, 16); close(f); }
break; }
case 5: { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); break; }
case 6: { char nm[64]; gethostname(nm, sizeof nm); break; }
case 7: { (void)getuid(); (void)getgid(); break; }
}
kill(pid, sigs[i % 4]);
}
ktrace(NULL, KTROP_CLEAR, KTRFAC_PSIG, pid);
/* Parse and dump padding bytes per record */
fd = open("/tmp/ktr_entropy.out", O_RDONLY);
if (fd < 0) { perror("open ro"); return 1; }
unsigned char b[224]; /* one record */
int rec = 0, hits = 0, zeros = 0;
unsigned int seen[64] = {0}; int n_seen = 0;
while (read(fd, b, sizeof b) == (ssize_t)sizeof b) {
unsigned int pad = b[60] | (b[61]<<8) | (b[62]<<16) | (b[63]<<24);
printf("rec %02d signo=%-3d PAD=0x%08x action=...\n",
rec++, b[56], pad);
if (pad == 0) zeros++;
else { hits++;
int dup = 0;
for (int k = 0; k < n_seen; k++) if (seen[k] == pad) dup = 1;
if (!dup && n_seen < 64) seen[n_seen++] = pad;
}
}
close(fd);
printf("\nSummary: %d records, %d non-zero, %d zero, %d unique non-zero values\n",
rec, hits, zeros, n_seen);
if (n_seen > 1) printf("ENTROPY CONFIRMED: padding carries varying kernel data\n");
return 0;
}