//nefariousplan

CVE-2026-36355: Realtek Commented Out Two of Three Debug Macros. The One In The Middle Maps Kernel Memory.

patterns

cve

proof of concept

CVE-2026-36355 is two IOCTLs in a Wi-Fi driver that let any local user read and write kernel memory. The handlers are named write_mem and read_mem. They are compiled in by a macro named _IOCTL_DEBUG_CMD_ on line 307 of 8192cd_cfg.h. The macros on the lines immediately above and below it are also debug macros. Both are commented out. The middle one ships to every OEM that licensed Realtek's rtl819x Jungle SDK.

Three macros. Two // prefixes. One bug.

Realtek licenses a Wi-Fi reference codebase, the rtl819x Jungle SDK, to the OEMs that build consumer routers, 4G/LTE CPEs, and the silicon-bundle modules embedded inside them. The SDK is closed-source under OEM contract, GPL by license, redistributed to end customers piecemeal by the integrators required to ship source when they ship a binary derived from it. The driver this CVE lives in, rtl8192cd, is the family driver behind every Wi-Fi chip in the line: RTL8192C/D/E/F, RTL8188EE, RTL8197F, the variants by SoC and form factor. A complete copy of the source lives in the vankel/rtl819x-SDK GitHub redistribution; this post quotes from it.

Lines 306 to 308 of 8192cd_cfg.h:

//#define _DEBUG_RTL8192CD_		// defined when debug print is used
#define _IOCTL_DEBUG_CMD_		// defined when read/write register/memory command is used in ioctl
//#define _AMPSDU_AMSDU_DEBUG_		// defined when debug for ampdu_amsdu

Three macros. Three inline comments stating, in Realtek's own words, what each macro does and that each is a debug feature. Two // prefixes. One line without.

_DEBUG_RTL8192CD_ gates panic_printk calls scattered through the driver. The comment says so. Realtek's release engineer commented it out. Production builds emit no debug traces, because at build time the macro is undefined and the #ifdef _DEBUG_RTL8192CD_ blocks around the prints compile to nothing.

_AMPSDU_AMSDU_DEBUG_ gates aggregation-protocol debug logging. The comment says so. Realtek's release engineer commented it out. Production builds emit no A-MPDU/A-MSDU traces.

_IOCTL_DEBUG_CMD_ gates a block of IOCTLs that read and write kernel memory from userspace. The comment next to it admits exactly that, in seven words: "defined when read/write register/memory command is used in ioctl". Realtek's release engineer did not comment it out. Production builds compile in the IOCTLs.

The release-prep discipline that produced the comment markers on the two neighbors did not produce one on the macro between them.

The handlers do exactly what their names say.

In 8192cd_ioctl.c, lines 87 and 88, Realtek defines two command numbers in the Linux wireless private-IOCTL range:

#define RTL8192CD_IOCTL_WRITE_MEM     (SIOCDEVPRIVATE + 0x5)  // 0x89f5
#define RTL8192CD_IOCTL_READ_MEM      (SIOCDEVPRIVATE + 0x6)  // 0x89f6

SIOCDEVPRIVATE+N is the Linux wireless extension's range for vendor-specific commands. Drivers register handlers under this range to expose tuning knobs and chipset diagnostics. A caller reaches them by opening an AF_INET SOCK_DGRAM socket and calling ioctl(fd, 0x89f5, &iwreq) with the request's ifr_name set to the wireless interface. Opening that socket requires no privilege on most kernels and GID 3003 (inet) on paranoid kernels.

The dispatcher's switch (cmd) block routes both IOCTLs to handlers inside an #ifdef _IOCTL_DEBUG_CMD_:

#ifdef _IOCTL_DEBUG_CMD_
    case RTL8192CD_IOCTL_WRITE_MEM:
        if ((wrq->u.data.length > sizeof_tmpbuf) ||
            ioctl_copy_from_user(tmpbuf, (void *)wrq->u.data.pointer, wrq->u.data.length))
            break;
        ret = write_mem(priv, tmpbuf);
        break;

    case RTL8192CD_IOCTL_READ_MEM:
        if ((wrq->u.data.length > sizeof_tmpbuf) ||
            ioctl_copy_from_user(tmpbuf, (void *)wrq->u.data.pointer, wrq->u.data.length))
                break;
        i = read_mem(priv, tmpbuf);
        if (i >= 0) {
            if ((i > 0) && ioctl_copy_to_user((void *)wrq->u.data.pointer, tmpbuf, i))
                break;
            wrq->u.data.length = i;
            ret = 0;
        }
        break;

With _IOCTL_DEBUG_CMD_ defined, both case arms compile. The kernel routes 0x89f5 to write_mem and the function below does what its name says:

static int write_mem(struct rtl8192cd_priv *priv, unsigned char *data)
{
    ...
    start = (unsigned long)_atoi((char *)tmpbuf, 16);
    ...
    len = _atoi((char *)tmpbuf, 16);
    val = (unsigned long)_atoi((char *)data, 16);
    ...
    for (i=0; i<len; i++) {
        memcpy((char *)start, (char *)&val, size);
        start += size;
    }
    return 0;
}

start is a kernel virtual address parsed out of a user-supplied string with _atoi(..., 16). len is unbounded. val is the 32-bit pattern to write. The loop memcpys val into start and advances. There is no capable(CAP_NET_ADMIN). There is no access_ok on the kernel-side pointer. There is no range check distinguishing 0xC0008000 (kernel image text) from 0x00400000 (userspace). The user typed an address as hex. The driver wrote to it.

read_mem is symmetric:

memcpy(org_ptr, (char *)start, copy_len);

org_ptr is the dispatcher's shared static buffer. start is the user-supplied address. The driver reads from kernel into the buffer; the dispatcher ioctl_copy_to_users the buffer back. The read primitive caps at 128 bytes per call. The write primitive does not cap.

The dispatcher's entry function, rtl8192cd_ioctl, takes no capability check. It declares its parameters, calls DEBUG_TRACE, locks an IRQ, and falls into switch (cmd). No if (!capable(...)). No ns_capable. No access_ok on the inbound IOCTL except one unrelated call at line 10373 for the priv-table query. The check the driver author was supposed to write is not in this file and is not anywhere in the call chain that reaches this file. The named control's only manifestation in the codebase is the macro name on line 307 of 8192cd_cfg.h.

Any user with a wireless socket is local root in forty-four IOCTLs.

Daniil Gordeev's kpwn.c, published alongside the CVE on a Linux 3.18 LTE CPE, runs from an unprivileged shell and finishes with execl("/bin/sh", ...) as uid 0. Forty-four IOCTLs on average. Zero hardcoded addresses.

The structure:

  1. Open AF_INET SOCK_DGRAM. Walk /sys/class/net/ and probe each interface with read_mem against a known kernel address (0xC0008000, the ARM kernel image base). The interface that returns nonzero is the rtl819x one.
  2. Brute-force the kernel .data segment (0xC0800000 to 0xC1000000 on ARM without KASLR), reading 32-word chunks until the byte sequence "swapper" shows up. That is init_task->comm. Validate by following the adjacent cred pointer and confirming it dereferences to {usage_refcount, uid=0, gid=0}.
  3. Bulk-read 0x600 bytes of init_task (twelve IOCTLs of 128 bytes each) and detect task_struct offsets in place. comm is the "swapper" byte sequence. cred is the kernel pointer immediately before it. tasks is the only list_head whose next does not self-reference and whose target dereferences to a printable comm. pid is the field that reads 0 in init_task and reads matching nonzero values in two follow-on tasks.
  4. Walk the task list backward from init_task (newest task first via tasks.prev) until the running PID is found.
  5. Two write_mem IOCTLs against the running task's cred: nine 32-bit zeros over cred + 0x04 (uid, gid, suid, sgid, euid, egid, fsuid, fsgid, securebits), eight 32-bit 0xFFFFFFFFs over cred + 0x28 (capability bitmasks for inheritable, permitted, effective, bounding set).
  6. getuid() returns 0. execl for a root shell.

The exploit's portability is a function of the primitive's directness. There is no shellcode, no ROP, no kernel-symbol assumption, no per-version offset table. task_struct layout varies across kernel releases; kpwn discovers it at runtime from a single anchor (the "swapper" byte sequence) and validates each offset against the kernel's own data. The driver and the running kernel are coupled by exactly one thing: the macro on line 307 and the memcpy it gates.

The macro guards eight handlers, not two.

The #ifdef _IOCTL_DEBUG_CMD_ block in 8192cd_ioctl.c covers more than write_mem and read_mem. Inside that block, between lines 10529 and 10609, the dispatcher routes eight private IOCTLs to handlers gated by the same macro:

#ifdef _IOCTL_DEBUG_CMD_
    case RTL8192CD_IOCTL_WRITE_REG:      /* 0x89f3 */
    case RTL8192CD_IOCTL_READ_REG:       /* 0x89f4 */
    case RTL8192CD_IOCTL_WRITE_MEM:      /* 0x89f5 */
    case RTL8192CD_IOCTL_READ_MEM:       /* 0x89f6 */
    case RTL8192CD_IOCTL_WRITE_BB_REG:   /* 0x89fa */
    case RTL8192CD_IOCTL_READ_BB_REG:    /* 0x89fb */
    case RTL8192CD_IOCTL_WRITE_RF_REG:   /* 0x89fc */
    case RTL8192CD_IOCTL_READ_RF_REG:    /* 0x89fd */
#endif // _IOCTL_DEBUG_CMD_

WRITE_REG and READ_REG expose the chip's MMIO register space (read or write a hardware register at a caller-supplied offset). WRITE_BB_REG and READ_BB_REG expose baseband configuration registers. WRITE_RF_REG and READ_RF_REG expose RF transceiver registers via PHY_SetRFReg and PHY_QueryRFReg. The hardware-register primitives are not equivalent to the kernel-memory primitives, but they cross the same trust boundary on the same dispatcher, gated by the same single macro, with the same absence of a capability check.

The CVE record names write_mem and read_mem because they yield the cleanest LPE chain on a typical embedded kernel. The patch a vendor writes to close CVE-2026-36355 will most likely add a capable() check to those two case arms and leave the other six in place. The other six are not the same bug. They are bugs of the same shape, sharing a single gate, written by the same author, shipped under the same release discipline. Anyone patching this CVE is patching one of eight.

The fix is two characters of comment marker.

The vczapi WordPress plugin shipped a check_ajax_referer call with // typed in front of it. We covered that CVE in the patch is two characters, the catalog's canonical strict-form exhibit of todo-that-shipped: a security control fully written into the source, its only manifestation a working line of code with two characters of comment marker in front of it. The patch was the absence of two characters somebody at some point typed. The line was in the file. The fix did not have to be authored. It had to be uncommented.

CVE-2026-36355 is the mirror form. The patch is the presence of two characters somebody at some point did not type. The line is in the file. The fix does not have to be authored. It has to be commented out.

The two characters are on the line above:

//#define _DEBUG_RTL8192CD_		// defined when debug print is used

The two characters are on the line below:

//#define _AMPSDU_AMSDU_DEBUG_		// defined when debug for ampdu_amsdu

They are not on the line between:

#define _IOCTL_DEBUG_CMD_		// defined when read/write register/memory command is used in ioctl

Some engineer at Realtek sat in a release-prep editor and prepared 8192cd_cfg.h for a production build. They commented out _DEBUG_RTL8192CD_ because console prints were noise. They commented out _AMPSDU_AMSDU_DEBUG_ because A-MPDU/A-MSDU traces had no place in a shipping image. They did not comment out _IOCTL_DEBUG_CMD_, because the kernel memory R/W IOCTLs were how the chipset got brought up during bringup, and the release-prep checklist did not name them as something to remove. The same file shipped to every OEM that licensed the SDK. The OEMs did not audit the file. The build process did not enforce the discipline. The macro stayed defined. The IOCTLs stayed compiled in.

The Realtek Jungle SDK's userspace surface produced CVE-2021-35395, the unauthenticated remote-code-execution bug in the SDK's webserver that received downstream advisories from D-Link, TOTOLINK, Tenda, NEC, I-O DATA, Elecom, Buffalo, Humax, H3C, and IP-COM. The kernel surface of the same SDK is what this CVE covers. Whether any specific product line of those vendors ships this exact driver build is a per-product question that requires reading the per-product GPL release. The macro is the cheap question. Search the redistributed source for _IOCTL_DEBUG_CMD_ in 8192cd_cfg.h; if the line is not prefixed with //, the device is shipping kernel memory to userspace on every wireless interface the driver runs.

This is debug-flag-left-on: a compile-time feature flag named with a debug convention, unconditionally #defined in production headers, with no #ifdef DEBUG or build-system gate enforcing the convention. The "debug only" contract has two manifestations in the codebase. One is the macro's name. One is an inline comment admitting what the macro does. The runtime sees neither.

PoC: totekuh/CVE-2026-36355

The patch is two characters. They are on the line above this macro. They are on the line below it. They are not on the middle line.