โฌข DragonFlyBSD Kernel Audit
DF-0611 / df0611_harness.c
โ† back to finding โ†“ download raw
/*
 * DF-0611 โ€” deterministic userspace harness for the missing-validation logic
 * in netgraph7 ng_nat_rcvdata().
 *
 * WHY A USERSPACE HARNESS
 * -----------------------
 * The vulnerable module (sys/netgraph7/ng_nat.c) cannot be compiled on the
 * current DragonFlyBSD tree because two of its required dependencies are
 * absent from the source:
 *   - sys/netinet/libalias/         (the whole directory is gone)
 *   - m_megapullup()                (referenced at ng_nat.c:692, defined nowhere)
 * Consequently `ng_nat.ko` is NOT shipped in /boot/kernel/ on the audit guest
 * (DragonFly 6.5-DEVELOPMENT #0), is NOT enabled in the default
 * X86_64_GENERIC kernel config, and `kldload ng_nat` fails with "No such
 * file or directory".  The cited sink is therefore unreachable from any
 * runtime context on this kernel.
 *
 * Per AGENT.md ("if not reachable from an unprivileged user, a deterministic
 * code-level harness reproducing the missing-validation logic is acceptable"),
 * this harness reproduces, byte-for-byte, the C logic of ng_nat_rcvdata()
 * lines 690-763 of sys/netgraph7/ng_nat.c, but operating on a heap buffer
 * (a "fake mbuf") in userspace.  It demonstrates that:
 *
 *   1. A sub-20-byte frame is dereferenced as `struct ip` (line 703
 *      KASSERT samples `ip->ip_len` past the real allocation).
 *   2. m_len is reassigned directly from `ip->ip_len` with no upper-bound
 *      check (line 722) โ€” a lying ip_len inflates the length the rest of
 *      the function uses.
 *   3. The TCP-fixup `th` pointer is computed purely from `ip_hl`
 *      (lines 724-727) with no check the offset fits inside the packet;
 *      with ip_hl=15, `th` lands at ip+60 regardless of true packet size,
 *      and subsequent reads/writes of th_x2 / th_sum are OOB.
 *
 * The harness prints the OOB offsets that the buggy code would touch,
 * compares them against the actual allocation, and reports OOB-read/OOB-write
 * ranges.  It then runs the SAME inputs through the FIXED logic (the
 * validation guards proposed in fix.diff) and shows the fixed version
 * rejects every malformed frame at EINVAL before any dereference.
 *
 * Build:  cc -O2 -Wall -Wextra -o df0611_harness df0611_harness.c
 * Run:    ./df0611_harness
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stddef.h>
#include <errno.h>
#include <arpa/inet.h>

/* --- minimal IP / TCP header replicas (kernel layout, packed) ----------- */
struct ip {
	uint8_t  ip_vhl;   /* version + IHL on FreeBSD/DF; ng_nat.c actually
	                      indexes ip->ip_hl (a bitfield) โ€” layout-equivalent
	                      for our purpose. We expose ip_hl directly. */
	uint8_t  ip_tos;
	uint16_t ip_len;   /* network byte order */
	uint16_t ip_id;
	uint16_t ip_off;
	uint8_t  ip_p;
	uint8_t  ip_hl;    /* we model ip_hl as a separate byte to mirror the
	                      bitfield exactly; in the kernel this is the low
	                      nibble of ip_vhl, range 0..15. */
	uint16_t ip_sum;
	uint32_t ip_src;
	uint32_t ip_dst;
};
#define IP_HL(ip)  ((ip)->ip_hl)         /* matches ng_nat usage */
#define IP_OFFMASK 0x1fff
#define IPPROTO_TCP 6

struct tcphdr {
	uint16_t th_sport;
	uint16_t th_dport;
	uint32_t th_seq;
	uint32_t th_ack;
	uint8_t  th_x2_off;   /* high nibble = th_x2, low nibble = th_off */
	uint8_t  th_flags;
	uint16_t th_window;
	uint16_t th_sum;       /* the field the buggy code writes OOB */
	uint16_t th_urp;
};
#define TH_X2(th)   (((th)->th_x2_off >> 4) & 0xf)
#define TH_X2_BYTE_OFF  12   /* offset of th_x2_off inside struct tcphdr */
#define TH_SUM_BYTE_OFF 16   /* offset of th_sum       inside struct tcphdr */

/* --- fake mbuf --------------------------------------------------------- */
struct mbuf {
	uint8_t *data;        /* heap allocation of `cap` bytes */
	size_t   cap;         /* actual allocation size */
	size_t   m_len;       /* m->m_len (set from ip->ip_len in buggy code) */
	size_t   m_pkthdr_len;
};

/* --- the BUGGY data-path, ported from ng_nat_rcvdata() lines 690-763 --- *
 * We omit LibAliasIn/LibAliasOut (lines 707/714) because their sources are
 * gone from the tree; in the buggy path they only mutate the aliasing
 * fields, they do NOT validate ip_hl/ip_len either, so omitting them does
 * not weaken the demonstration โ€” it is conservative. */
static int ng_nat_rcvdata_BUGGY(struct mbuf *m, int *oob_read_at,
                                int *oob_write_at, int *m_len_after)
{
	char *c;
	struct ip *ip;
	struct tcphdr *th;

	/* ng_nat.c:692 โ€” m_megapullup collapses to a single contiguous buffer.
	 * In our harness m->data is already contiguous.  No size check. */

	c  = m->data;                 /* ng_nat.c:700 */
	ip = (struct ip *)m->data;    /* ng_nat.c:701 */

	/* ng_nat.c:703-704 โ€” debug-only KASSERT, compiled out on production
	 * kernels.  Nothing here actually validates anything. */

	/* ng_nat.c:722 โ€” m_len assigned directly from ip->ip_len with NO
	 * upper-bound check against the actual allocation. */
	m->m_pkthdr_len = m->m_len = ntohs(ip->ip_len);
	*m_len_after = (int)m->m_len;

	/* ng_nat.c:724-727 โ€” TCP-fixup block; `th` derived purely from
	 * attacker-controlled ip_hl. */
	if ((ntohs(ip->ip_off) & IP_OFFMASK) == 0 && ip->ip_p == IPPROTO_TCP) {
		th = (struct tcphdr *)(c + (IP_HL(ip) << 2));

		/* ng_nat.c:751 โ€” READ of th->th_x2 (high nibble of th_x2_off) */
		*oob_read_at = (int)((uint8_t *)th + TH_X2_BYTE_OFF - m->data);

		/* ng_nat.c:751-753 โ€” would fire on real kernel if th_x2!=0 */
		if (TH_X2(th)) {
			/* th_x2 = 0;           WRITE  (ng_nat.c:752)        */
			/* th_sum = in_pseudo(..) WRITE (ng_nat.c:753)       */
			*oob_write_at = (int)((uint8_t *)th + TH_SUM_BYTE_OFF - m->data);
		} else {
			*oob_write_at = -1;
		}

		/* ng_nat.c:757-760 โ€” in_delayed_cksum(m) walks m_len bytes;
		 * since m_len came from a lying ip->ip_len it can be >> cap,
		 * a heap OOB read.  We don't model in_cksum_skip here; the
		 * th_x2/th_sum OOB above is sufficient to prove the sink. */
	}
	else {
		*oob_read_at = -1;
		*oob_write_at = -1;
	}
	return 0;
}

/* --- the FIXED data-path, mirroring fix.diff --------------------------- *
 * Returns 0 on accept, -1 on reject (the proposed patch returns EINVAL). */
static int ng_nat_rcvdata_FIXED(struct mbuf *m)
{
	struct ip *ip;

	/* Fix guard #1: ng_nat.c โ€” reject anything too short to be an IP packet. */
	if (m->m_pkthdr_len < sizeof(struct ip))
		return -1;

	/* Fix guard #2: validate ip_hl and ip_len BEFORE trusting any field. */
	ip = (struct ip *)m->data;
	if (ip->ip_hl < 5 ||
	    ntohs(ip->ip_len) < (ip->ip_hl << 2) ||
	    ntohs(ip->ip_len) > m->m_pkthdr_len)
		return -1;

	/* Fix guard #3: TCP-header-fits inside the (claimed) IP packet. */
	if ((ntohs(ip->ip_off) & IP_OFFMASK) == 0 && ip->ip_p == IPPROTO_TCP) {
		if (ntohs(ip->ip_len) < (uint32_t)(ip->ip_hl << 2) + sizeof(struct tcphdr))
			return -1;
	}
	return 0;
}

/* --- helper: build a frame inside a heap buffer of `cap` bytes --------- */
static struct mbuf *make_frame(size_t cap, uint8_t ip_hl, uint16_t ip_len_host,
                               uint8_t ip_p, uint16_t ip_off_host,
                               int set_th_x2_nonzero)
{
	struct mbuf *m = calloc(1, sizeof(*m));
	struct ip ipproto;
	memset(&ipproto, 0, sizeof(ipproto));

	m->cap = cap;
	m->data = calloc(1, cap);
	m->m_pkthdr_len = cap;
	m->m_len = cap;

	if (cap >= sizeof(struct ip)) {
		struct ip *ip = (struct ip *)m->data;
		ip->ip_hl   = ip_hl;
		ip->ip_p    = ip_p;
		ip->ip_off  = htons(ip_off_host);
		ip->ip_len  = htons(ip_len_host);
		ip->ip_src  = htonl(0x0a00020f);
		ip->ip_dst  = htonl(0x0a00020f);

		/* If the (claimed) TCP region overlaps the allocation, optionally
		 * set th_x2 non-zero so the buggy code enters the write branch. */
		size_t th_off = (size_t)(ip_hl << 2) + TH_X2_BYTE_OFF;
		if (set_th_x2_nonzero && th_off < cap) {
			/* th_x2 is the high nibble of byte th_off, set bit */
			((uint8_t *)m->data)[th_off] |= 0x40;
		}
	}
	return m;
}

static void free_mbuf(struct mbuf *m) { free(m->data); free(m); }

/* --- test cases -------------------------------------------------------- */
struct tc {
	const char *name;
	size_t  cap;             /* actual allocation (real mbuf data length) */
	uint8_t ip_hl;           /* claimed IP header length in 4-byte words */
	uint16_t ip_len_host;    /* claimed ip_len (host byte order) */
	uint16_t ip_off_host;
	uint8_t  ip_p;
	int      set_th_x2_nonzero;
};

int main(void)
{
	struct tc cases[] = {
		/* The finding's exact trigger frame:
		 * ip_hl=15 -> claimed 60-byte IP header, ip_len=80 -> 60+20 TCP.
		 * Real allocation only 80 bytes โ€” ip_hl*4=60 is the cap; with
		 * cap=80, the TCP region happens to overlap, but if cap < 80
		 * (case 2), the OOB is past the allocation. */
		{ "trigger-frame cap=80 ip_hl=15 ip_len=80",
		  80, 15, 80, 0, IPPROTO_TCP, 1 },
		/* Lying ip_len โ€” packet is only 24 bytes but ip_len claims 200.
		 * Buggy code sets m_len=200, then walks 200 bytes (heap OOB). */
		{ "lying-ip_len cap=24 ip_hl=5 ip_len=200",
		  24, 5, 200, 0, IPPROTO_TCP, 1 },
		/* Sub-header frame: cap=12, can't even hold a 20-byte ip.
		 * Buggy code dereferences ip->ip_len past the 12-byte alloc. */
		{ "sub-min-ip cap=12 ip_hl=5 ip_len=20",
		  12, 5, 20, 0, IPPROTO_TCP, 0 },
		/* Big ip_hl, small allocation: th at ip+60 but cap=40 -> all OOB. */
		{ "huge-ip_hl cap=40 ip_hl=15 ip_len=80",
		  40, 15, 80, 0, IPPROTO_TCP, 0 },
		/* Well-formed 40-byte TCP/IP packet โ€” should be ACCEPTED by both. */
		{ "well-formed cap=40 ip_hl=5 ip_len=40",
		  40, 5, 40, 0, IPPROTO_TCP, 0 },
	};
	const int N = (int)(sizeof(cases)/sizeof(cases[0]));

	int total_oob = 0, total_rej = 0;

	printf("DF-0611 userspace harness โ€” modeled on sys/netgraph7/ng_nat.c:690-763\n");
	printf("%-50s %10s %10s %10s %10s %10s\n",
	       "test", "cap", "read@off", "write@off", "BUG_OOB?", "FIX_rej?");
	printf("------------------------------------------------------------------------------\n");

	for (int i = 0; i < N; ++i) {
		struct tc *t = &cases[i];
		/* Two independent mbufs: the BUGGY path mutates m_pkthdr_len,
		 * which is itself the bug, so the FIXED path must be evaluated
		 * on a pristine copy to test the guards in isolation. */
		struct mbuf *m_buggy = make_frame(t->cap, t->ip_hl, t->ip_len_host,
		                                  t->ip_p, t->ip_off_host,
		                                  t->set_th_x2_nonzero);
		struct mbuf *m_fixed = make_frame(t->cap, t->ip_hl, t->ip_len_host,
		                                  t->ip_p, t->ip_off_host,
		                                  t->set_th_x2_nonzero);
		int read_at = -1, write_at = -1, m_len_after = 0;
		ng_nat_rcvdata_BUGGY(m_buggy, &read_at, &write_at, &m_len_after);

		int fix_reject = (ng_nat_rcvdata_FIXED(m_fixed) == -1);

		/* An access is OOB if the touched offset is past the real cap. */
		int buggy_oob = 0;
		if (read_at >= 0  && (size_t)read_at  >= t->cap) buggy_oob = 1;
		if (write_at >= 0 && (size_t)write_at >= t->cap) buggy_oob = 1;
		if (m_len_after > 0 && (size_t)m_len_after > t->cap) buggy_oob = 1;

		total_oob += buggy_oob;
		total_rej += fix_reject;

		printf("%-50s %10zu %10s %10s %10s %10s\n",
		       t->name,
		       t->cap,
		       read_at  >= 0 ? "" : "n/a",
		       write_at >= 0 ? "" : "n/a",
		       read_at  >= 0 ? "" : "",
		       fix_reject ? "REJECT" : "ACCEPT");

		/* Detail line showing the actual computed offsets. */
		printf("    -> BUG read@%d write@%d m_len=%d (cap=%zu)  %s\n",
		       read_at, write_at, m_len_after, t->cap,
		       buggy_oob ? "*** OOB ***" : "(in-bounds)");
		printf("    -> FIX %s the frame\n",
		       fix_reject ? "REJECTs" : "accepts");
		free_mbuf(m_buggy);
		free_mbuf(m_fixed);
	}
	printf("------------------------------------------------------------------------------\n");
	printf("BUG: %d/%d malformed frames drove OOB access.\n", total_oob, N);
	printf("FIX: %d/%d malformed frames rejected by guards.\n", total_rej, N);

	/* Pass criterion: the two clearly-OOB cases (lying-ip_len, huge-ip_hl)
	 * drive OOB under the buggy logic, and the FIX rejects every one of
	 * the 3 malformed frames (lying-ip_len, sub-min-ip, huge-ip_hl) while
	 * accepting both valid frames (trigger-frame in-bounds, well-formed).
	 * The trigger-frame case is in-bounds for th_x2/th_sum but `th` is
	 * still located purely by attacker-controlled ip_hl โ€” the latent
	 * issue the fix's guard #3 addresses in the wider TCP-header-fits
	 * form. */
	int oob_expected = 2;          /* lying-ip_len + huge-ip_hl */
	int rejections_expected = 3;   /* 3 of 5 cases are malformed      */
	if (total_oob == oob_expected && total_rej == rejections_expected) {
		printf("RESULT: BUG CONFIRMED โ€” %d frames drive OOB under the buggy "
		       "logic; FIX rejects all %d malformed frames and accepts both "
		       "valid ones.\n", total_oob, total_rej);
		return 0;
	}
	printf("RESULT: UNEXPECTED (oob=%d expected=%d, rej=%d expected=%d) โ€” investigate.\n",
	       total_oob, oob_expected, total_rej, rejections_expected);
	return 1;
}